orchestrix-yuri 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -34
- package/lib/gateway/config.js +9 -0
- package/lib/gateway/engine/claude-tmux.js +674 -0
- package/lib/gateway/index.js +1 -0
- package/lib/gateway/router.js +14 -5
- package/package.json +1 -1
- package/lib/gateway/engine/claude-cli.js +0 -225
package/README.md
CHANGED
|
@@ -8,7 +8,10 @@ User describes idea → Yuri drives: Create → Plan → Develop → Test → De
|
|
|
8
8
|
|
|
9
9
|
## How It Works
|
|
10
10
|
|
|
11
|
-
Yuri is a [Claude Code skill](https://code.claude.com/docs/en/skills)
|
|
11
|
+
Yuri is a [Claude Code skill](https://code.claude.com/docs/en/skills) + Channel Gateway. It can be used in two ways:
|
|
12
|
+
|
|
13
|
+
1. **Terminal mode** — activate `/yuri` inside any Claude Code session
|
|
14
|
+
2. **Telegram mode** — chat with Yuri via Telegram bot, backed by a persistent tmux Claude Code session
|
|
12
15
|
|
|
13
16
|
| Phase | What Yuri Does | Agents Involved |
|
|
14
17
|
|-------|---------------|-----------------|
|
|
@@ -18,33 +21,60 @@ Yuri is a [Claude Code skill](https://code.claude.com/docs/en/skills) that orche
|
|
|
18
21
|
| **4. Test** | Runs smoke tests per epic, fixes bugs, regression tests | QA + Dev |
|
|
19
22
|
| **5. Deploy** | Recommends and executes deployment strategy | — |
|
|
20
23
|
|
|
24
|
+
## Prerequisites
|
|
25
|
+
|
|
26
|
+
- **Node.js** >= 18
|
|
27
|
+
- **[Claude Code](https://claude.com/claude-code)** CLI installed and logged in
|
|
28
|
+
- **[tmux](https://github.com/tmux/tmux)** installed (`brew install tmux` on macOS, `apt install tmux` on Linux)
|
|
29
|
+
- **Orchestrix License Key** (get one at [orchestrix-mcp.youlidao.ai](https://orchestrix-mcp.youlidao.ai))
|
|
30
|
+
- **Telegram Bot Token** (for Telegram mode — get from [@BotFather](https://t.me/BotFather))
|
|
31
|
+
|
|
21
32
|
## Installation
|
|
22
33
|
|
|
34
|
+
### Method A: From npm (recommended)
|
|
35
|
+
|
|
23
36
|
```bash
|
|
24
|
-
|
|
37
|
+
# Install globally
|
|
38
|
+
npm install -g orchestrix-yuri
|
|
39
|
+
|
|
40
|
+
# Initialize skill + global memory
|
|
41
|
+
orchestrix-yuri install
|
|
42
|
+
|
|
43
|
+
# Start Telegram gateway
|
|
44
|
+
orchestrix-yuri serve --telegram-token "YOUR_BOT_TOKEN"
|
|
25
45
|
```
|
|
26
46
|
|
|
27
|
-
|
|
47
|
+
### Method B: From source
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/anthropics/orchestrix-yuri.git
|
|
51
|
+
cd orchestrix-yuri
|
|
52
|
+
npm install
|
|
53
|
+
|
|
54
|
+
# Initialize skill + global memory
|
|
55
|
+
node bin/install.js install
|
|
56
|
+
|
|
57
|
+
# Start Telegram gateway
|
|
58
|
+
node bin/serve.js --telegram-token "YOUR_BOT_TOKEN"
|
|
59
|
+
```
|
|
28
60
|
|
|
29
|
-
###
|
|
61
|
+
### What `install` does
|
|
30
62
|
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
63
|
+
- Copies the Yuri skill to `~/.claude/skills/yuri/`
|
|
64
|
+
- Initializes global memory at `~/.yuri/` (identity, boss profile, portfolio registry, focus, wisdom)
|
|
65
|
+
- Creates channel config at `~/.yuri/config/channels.yaml`
|
|
34
66
|
|
|
35
67
|
## Usage
|
|
36
68
|
|
|
69
|
+
### Terminal Mode
|
|
70
|
+
|
|
37
71
|
In any Claude Code session:
|
|
38
72
|
|
|
39
73
|
```
|
|
40
74
|
/yuri
|
|
41
75
|
```
|
|
42
76
|
|
|
43
|
-
Then either:
|
|
44
|
-
- Describe your project idea in natural language
|
|
45
|
-
- Use a specific command: `*create`, `*plan`, `*develop`, `*test`, `*deploy`
|
|
46
|
-
|
|
47
|
-
### Commands
|
|
77
|
+
Then either describe your project idea in natural language, or use a specific command:
|
|
48
78
|
|
|
49
79
|
| Command | Description |
|
|
50
80
|
|---------|-------------|
|
|
@@ -57,43 +87,133 @@ Then either:
|
|
|
57
87
|
| `*resume` | Resume from last saved checkpoint |
|
|
58
88
|
| `*change "{desc}"` | Handle mid-project requirement change |
|
|
59
89
|
|
|
60
|
-
|
|
90
|
+
### Telegram Mode
|
|
91
|
+
|
|
92
|
+
Start the gateway and chat with Yuri via your Telegram bot:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# With token as CLI argument
|
|
96
|
+
orchestrix-yuri serve --telegram-token "YOUR_BOT_TOKEN"
|
|
61
97
|
|
|
98
|
+
# Or configure in ~/.yuri/config/channels.yaml first
|
|
99
|
+
orchestrix-yuri serve
|
|
62
100
|
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
101
|
+
|
|
102
|
+
#### Channel Configuration
|
|
103
|
+
|
|
104
|
+
Edit `~/.yuri/config/channels.yaml`:
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
server:
|
|
108
|
+
port: 7890
|
|
109
|
+
|
|
110
|
+
channels:
|
|
111
|
+
telegram:
|
|
112
|
+
enabled: true
|
|
113
|
+
token: "YOUR_BOT_TOKEN"
|
|
114
|
+
mode: polling
|
|
115
|
+
owner_chat_id: "" # Auto-bound on first /start
|
|
116
|
+
|
|
117
|
+
engine:
|
|
118
|
+
skill: yuri
|
|
119
|
+
tmux_session: yuri-gateway
|
|
120
|
+
startup_timeout: 30000
|
|
121
|
+
poll_interval: 2000
|
|
122
|
+
timeout: 300000
|
|
123
|
+
autocompact_pct: 80
|
|
124
|
+
compact_every: 50
|
|
70
125
|
```
|
|
71
126
|
|
|
127
|
+
#### First-time Telegram setup
|
|
128
|
+
|
|
129
|
+
1. Start the gateway: `orchestrix-yuri serve --telegram-token "YOUR_TOKEN"`
|
|
130
|
+
2. Open Telegram, find your bot, send `/start`
|
|
131
|
+
3. The first user to send `/start` becomes the owner (all others are rejected)
|
|
132
|
+
4. Send any message to interact with Yuri
|
|
133
|
+
|
|
134
|
+
## Architecture
|
|
135
|
+
|
|
136
|
+
### Persistent tmux Engine
|
|
137
|
+
|
|
138
|
+
The Telegram gateway runs Claude Code in a persistent tmux session (`yuri-gateway`), not as a one-shot subprocess. This means:
|
|
139
|
+
|
|
140
|
+
- **MCP servers connect once** and stay connected across messages
|
|
141
|
+
- **Conversation context is preserved** natively by Claude Code
|
|
142
|
+
- **No cold-start per message** — only the first message incurs startup latency
|
|
143
|
+
|
|
144
|
+
State detection uses Claude Code's TUI indicators:
|
|
145
|
+
|
|
146
|
+
| Symbol | State | Description |
|
|
147
|
+
|--------|-------|-------------|
|
|
148
|
+
| `○` | Idle | Waiting for input |
|
|
149
|
+
| `●` | Processing | Generating a response |
|
|
150
|
+
| `◐` | Approval | Permission prompt (auto-approved) |
|
|
151
|
+
| `[Verb]ed for Ns` | Complete | Response finished (e.g. "Baked for 31s") |
|
|
152
|
+
|
|
153
|
+
### Context Management (3-layer)
|
|
154
|
+
|
|
155
|
+
1. **CLAUDE.md persistence** — core instructions are written to project CLAUDE.md, which survives auto-compact
|
|
156
|
+
2. **Early auto-compact** — `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=80` triggers compaction at 80% (not the default 95%)
|
|
157
|
+
3. **Proactive /compact** — every 50 messages, a `/compact` is sent to keep context lean
|
|
158
|
+
|
|
72
159
|
### Memory System
|
|
73
160
|
|
|
74
|
-
Yuri maintains
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
161
|
+
Yuri maintains a four-layer global memory at `~/.yuri/`:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
~/.yuri/
|
|
165
|
+
├── self.yaml # Yuri identity
|
|
166
|
+
├── boss/
|
|
167
|
+
│ ├── profile.yaml # Boss profile
|
|
168
|
+
│ └── preferences.yaml # Boss preferences
|
|
169
|
+
├── portfolio/
|
|
170
|
+
│ ├── registry.yaml # All projects (active/archived)
|
|
171
|
+
│ ├── priorities.yaml # Portfolio priorities
|
|
172
|
+
│ └── relationships.yaml # Project relationships
|
|
173
|
+
├── focus.yaml # Current focus & state
|
|
174
|
+
├── config/
|
|
175
|
+
│ └── channels.yaml # Gateway channel config
|
|
176
|
+
├── chat-history/ # JSONL per chat_id
|
|
177
|
+
├── inbox.jsonl # Observation signals
|
|
178
|
+
└── wisdom/ # Accumulated knowledge
|
|
179
|
+
```
|
|
79
180
|
|
|
80
181
|
### tmux Sessions
|
|
81
182
|
|
|
82
183
|
| Session | Purpose | Windows |
|
|
83
184
|
|---------|---------|---------|
|
|
185
|
+
| `yuri-gateway` | Telegram channel gateway | 1 (Claude Code interactive) |
|
|
84
186
|
| `op-{project}` | Planning phase | One per agent (Analyst, PM, UX, Architect, PO) |
|
|
85
187
|
| `orchestrix-{repo-id}` | Development phase | 4 fixed (Architect, SM, Dev, QA) |
|
|
86
188
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
### Completion Detection
|
|
189
|
+
### File Structure
|
|
90
190
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
191
|
+
```
|
|
192
|
+
orchestrix-yuri/
|
|
193
|
+
├── bin/
|
|
194
|
+
│ ├── install.js # CLI entry (install / serve / migrate)
|
|
195
|
+
│ └── serve.js # Gateway launcher
|
|
196
|
+
├── lib/
|
|
197
|
+
│ ├── installer.js # Global install logic
|
|
198
|
+
│ ├── migrate.js # v1 → v2 memory migration
|
|
199
|
+
│ └── gateway/
|
|
200
|
+
│ ├── index.js # startGateway()
|
|
201
|
+
│ ├── config.js # Config loading + defaults
|
|
202
|
+
│ ├── router.js # Message routing + 5-engine orchestration
|
|
203
|
+
│ ├── binding.js # Owner authentication
|
|
204
|
+
│ ├── history.js # Chat history (JSONL)
|
|
205
|
+
│ ├── channels/
|
|
206
|
+
│ │ └── telegram.js # grammy Telegram adapter
|
|
207
|
+
│ └── engine/
|
|
208
|
+
│ └── claude-tmux.js # Persistent tmux session engine
|
|
209
|
+
└── skill/
|
|
210
|
+
├── SKILL.md # Agent persona
|
|
211
|
+
├── tasks/ # Phase workflow instructions
|
|
212
|
+
├── scripts/ # Shell scripts (tmux, monitoring)
|
|
213
|
+
├── templates/ # Memory schema
|
|
214
|
+
├── data/ # Decision rules
|
|
215
|
+
└── resources/ # MCP config, hooks, tmux scripts
|
|
216
|
+
```
|
|
97
217
|
|
|
98
218
|
## Change Management
|
|
99
219
|
|
|
@@ -117,6 +237,25 @@ Yuri handles mid-project changes based on scope:
|
|
|
117
237
|
| Overseas | Railway | Backend APIs |
|
|
118
238
|
| Overseas | AWS / GCP | Enterprise |
|
|
119
239
|
|
|
240
|
+
## Troubleshooting
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
# Check prerequisites
|
|
244
|
+
tmux -V && which claude && node -v
|
|
245
|
+
|
|
246
|
+
# View gateway logs (all output goes to stdout)
|
|
247
|
+
orchestrix-yuri serve --telegram-token "..." 2>&1 | tee gateway.log
|
|
248
|
+
|
|
249
|
+
# Check if tmux session is alive
|
|
250
|
+
tmux ls
|
|
251
|
+
|
|
252
|
+
# Peek into the Claude Code session
|
|
253
|
+
tmux attach -t yuri-gateway
|
|
254
|
+
|
|
255
|
+
# Manual cleanup if session gets stuck
|
|
256
|
+
tmux kill-session -t yuri-gateway
|
|
257
|
+
```
|
|
258
|
+
|
|
120
259
|
## License
|
|
121
260
|
|
|
122
261
|
MIT
|
package/lib/gateway/config.js
CHANGED
|
@@ -19,6 +19,15 @@ const DEFAULTS = {
|
|
|
19
19
|
},
|
|
20
20
|
engine: {
|
|
21
21
|
skill: 'yuri',
|
|
22
|
+
tmux_session: 'yuri-gateway',
|
|
23
|
+
startup_timeout: 30000, // 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
|
+
timeout: 300000, // per-message timeout (5 min)
|
|
28
|
+
history_limit: 10000, // tmux scrollback lines
|
|
29
|
+
autocompact_pct: 80, // trigger auto-compact at this % (default 95%)
|
|
30
|
+
compact_every: 50, // proactive /compact after N messages
|
|
22
31
|
},
|
|
23
32
|
};
|
|
24
33
|
|
|
@@ -0,0 +1,674 @@
|
|
|
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
|
+
|
|
12
|
+
// ── Shared Utilities (formerly in claude-cli.js) ───────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load L1 (global context) files and compose them into a context block.
|
|
16
|
+
* Injected into the prompt so Claude does not need to "remember" to read them.
|
|
17
|
+
*/
|
|
18
|
+
function loadL1Context() {
|
|
19
|
+
const files = [
|
|
20
|
+
{ label: 'Yuri Identity', path: path.join(YURI_GLOBAL, 'self.yaml') },
|
|
21
|
+
{ label: 'Boss Profile', path: path.join(YURI_GLOBAL, 'boss', 'profile.yaml') },
|
|
22
|
+
{ label: 'Boss Preferences', path: path.join(YURI_GLOBAL, 'boss', 'preferences.yaml') },
|
|
23
|
+
{ label: 'Portfolio Registry', path: path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml') },
|
|
24
|
+
{ label: 'Global Focus', path: path.join(YURI_GLOBAL, 'focus.yaml') },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const sections = [];
|
|
28
|
+
for (const f of files) {
|
|
29
|
+
if (fs.existsSync(f.path)) {
|
|
30
|
+
const content = fs.readFileSync(f.path, 'utf8').trim();
|
|
31
|
+
if (content) {
|
|
32
|
+
sections.push(`### ${f.label}\n\`\`\`yaml\n${content}\n\`\`\``);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return sections.length > 0
|
|
38
|
+
? `## Yuri Global Memory (L1 — pre-loaded)\n\n${sections.join('\n\n')}`
|
|
39
|
+
: '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Determine which project the message likely relates to, based on portfolio.
|
|
44
|
+
*/
|
|
45
|
+
function resolveProjectRoot() {
|
|
46
|
+
const registryPath = path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml');
|
|
47
|
+
if (!fs.existsSync(registryPath)) return null;
|
|
48
|
+
|
|
49
|
+
const registry = yaml.load(fs.readFileSync(registryPath, 'utf8')) || {};
|
|
50
|
+
const projects = registry.projects || [];
|
|
51
|
+
const active = projects.filter((p) => p.status === 'active');
|
|
52
|
+
|
|
53
|
+
if (active.length === 0) return null;
|
|
54
|
+
|
|
55
|
+
// Check global focus for active project
|
|
56
|
+
const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
|
|
57
|
+
if (fs.existsSync(focusPath)) {
|
|
58
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
59
|
+
if (focus.active_project) {
|
|
60
|
+
const match = active.find((p) => p.id === focus.active_project);
|
|
61
|
+
if (match && fs.existsSync(match.root)) return match.root;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Fallback: first active project
|
|
66
|
+
if (active[0] && fs.existsSync(active[0].root)) return active[0].root;
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Find the claude binary path.
|
|
73
|
+
* Shell aliases (like `cc`) are not available in child_process, so we
|
|
74
|
+
* resolve the actual binary via the user's login shell PATH.
|
|
75
|
+
*/
|
|
76
|
+
function findClaudeBinary() {
|
|
77
|
+
// Primary: resolve via user's login shell (handles all install methods)
|
|
78
|
+
try {
|
|
79
|
+
const resolved = execSync('zsh -lc "which claude" 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
80
|
+
if (resolved && fs.existsSync(resolved)) {
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// fall through
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Fallback: check common install locations
|
|
88
|
+
const candidates = [
|
|
89
|
+
'/usr/local/bin/claude',
|
|
90
|
+
'/opt/homebrew/bin/claude',
|
|
91
|
+
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
|
|
92
|
+
path.join(os.homedir(), '.local', 'bin', 'claude'),
|
|
93
|
+
path.join(os.homedir(), '.claude', 'bin', 'claude'),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
if (fs.existsSync(candidate)) {
|
|
98
|
+
return candidate;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Last resort: let the shell find it
|
|
103
|
+
return 'claude';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Cache the binary path
|
|
107
|
+
let _claudeBinary = null;
|
|
108
|
+
function getClaudeBinary() {
|
|
109
|
+
if (!_claudeBinary) {
|
|
110
|
+
_claudeBinary = findClaudeBinary();
|
|
111
|
+
console.log(`[claude-tmux] Using binary: ${_claudeBinary}`);
|
|
112
|
+
}
|
|
113
|
+
return _claudeBinary;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Session Configuration ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
const DEFAULT_SESSION = 'yuri-gateway';
|
|
119
|
+
const HISTORY_LIMIT = 10000;
|
|
120
|
+
|
|
121
|
+
// ── Singleton State ────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
let _sessionName = null;
|
|
124
|
+
let _sessionReady = false;
|
|
125
|
+
let _initPromise = null;
|
|
126
|
+
let _messageQueue = Promise.resolve();
|
|
127
|
+
let _messageCount = 0; // messages since last compact/session start
|
|
128
|
+
|
|
129
|
+
// ── Utilities ──────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function tmux(cmd) {
|
|
132
|
+
return execSync(`tmux ${cmd}`, { encoding: 'utf8', timeout: 10000 }).trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tmuxSafe(cmd) {
|
|
136
|
+
try {
|
|
137
|
+
return tmux(cmd);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Claude Code TUI Indicators ─────────────────────────────────────────────────
|
|
144
|
+
//
|
|
145
|
+
// Claude Code uses three circle symbols as primary state indicators:
|
|
146
|
+
//
|
|
147
|
+
// ○ (U+25CB) IDLE — Claude is waiting for user input
|
|
148
|
+
// ● (U+25CF) PROCESSING — Claude is actively generating a response
|
|
149
|
+
// ◐ (U+25D0) APPROVAL — Claude is waiting for permission approval
|
|
150
|
+
//
|
|
151
|
+
// During processing, a Braille spinner animates: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
|
|
152
|
+
// with cycling verbs like "Baking...", "Computing...", "Thinking..."
|
|
153
|
+
//
|
|
154
|
+
// Completion message format (past-tense verb + duration):
|
|
155
|
+
// "Baked for 31s", "Worked for 2m 45s", "Cooked for 1m 6s"
|
|
156
|
+
// Pattern: /[A-Z][a-z]*ed for \d+/
|
|
157
|
+
//
|
|
158
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
const BRAILLE_SPINNER = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
|
|
161
|
+
const COMPLETION_RE = /[A-Z][a-z]*ed for \d+/;
|
|
162
|
+
const IDLE_RE = /○/;
|
|
163
|
+
const PROCESSING_RE = /●/;
|
|
164
|
+
const APPROVAL_RE = /◐/;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Strip TUI chrome from captured pane output.
|
|
168
|
+
* `tmux capture-pane -p` (without -e) already strips most ANSI codes,
|
|
169
|
+
* but we clean up residual artifacts and Claude Code UI elements.
|
|
170
|
+
*/
|
|
171
|
+
function stripChrome(raw) {
|
|
172
|
+
return raw
|
|
173
|
+
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '') // ANSI CSI escapes
|
|
174
|
+
.replace(/\x1B\].*?\x07/g, '') // OSC sequences
|
|
175
|
+
.replace(/[○●◐◑]/g, '') // TUI state indicators
|
|
176
|
+
.replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '') // Braille spinner frames
|
|
177
|
+
.replace(/[⏵━─█·…→❯]/g, '') // UI decoration chars
|
|
178
|
+
.replace(/^\s*\d+\s*[│|]\s*/gm, '') // line-number gutter
|
|
179
|
+
.replace(/^.*[A-Z][a-z]*ed for \d+.*$/gm, '') // completion stats (all verbs)
|
|
180
|
+
.replace(/^.*[A-Z][a-z]*ing\.{3}.*$/gm, '') // spinner verb lines ("Baking...")
|
|
181
|
+
.replace(/^\s*$/gm, '') // blank lines
|
|
182
|
+
.trim();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Session Lifecycle ──────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
function hasSession(name) {
|
|
188
|
+
return tmuxSafe(`has-session -t ${name} 2>/dev/null`) !== null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function capturePaneRaw(name, lines) {
|
|
192
|
+
return tmuxSafe(`capture-pane -t ${name}:0 -p -S -${lines || 500}`) || '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get the last N lines of the pane output for state detection.
|
|
197
|
+
*/
|
|
198
|
+
function paneTail(name, n) {
|
|
199
|
+
return capturePaneRaw(name, n || 10);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Detect Claude Code's current state from pane output.
|
|
204
|
+
*
|
|
205
|
+
* @returns {'idle'|'processing'|'approval'|'complete'|'unknown'}
|
|
206
|
+
*/
|
|
207
|
+
function detectState(name) {
|
|
208
|
+
const tail = paneTail(name, 15);
|
|
209
|
+
|
|
210
|
+
// Priority 1: Completion message — most reliable signal
|
|
211
|
+
// e.g. "Baked for 31s", "Worked for 2m 45s"
|
|
212
|
+
if (COMPLETION_RE.test(tail)) {
|
|
213
|
+
return 'complete';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Priority 2: Approval prompt — needs immediate response
|
|
217
|
+
if (APPROVAL_RE.test(tail)) {
|
|
218
|
+
return 'approval';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Priority 3: Idle indicator — waiting for input
|
|
222
|
+
if (IDLE_RE.test(tail)) {
|
|
223
|
+
return 'idle';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Priority 4: Processing indicator — still working
|
|
227
|
+
if (PROCESSING_RE.test(tail) || BRAILLE_SPINNER.test(tail)) {
|
|
228
|
+
return 'processing';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return 'unknown';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Detect if Claude Code is idle (ready for input).
|
|
236
|
+
* Checks for ○ idle indicator or completion message.
|
|
237
|
+
*/
|
|
238
|
+
function isIdle(name) {
|
|
239
|
+
const state = detectState(name);
|
|
240
|
+
return state === 'idle' || state === 'complete';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Detect if Claude Code is showing an approval prompt (◐).
|
|
245
|
+
*/
|
|
246
|
+
function isApprovalPrompt(name) {
|
|
247
|
+
return detectState(name) === 'approval';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Detect if Claude Code is actively processing (● or spinner).
|
|
252
|
+
*/
|
|
253
|
+
function isProcessing(name) {
|
|
254
|
+
return detectState(name) === 'processing';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Context Management ─────────────────────────────────────────────────────────
|
|
258
|
+
//
|
|
259
|
+
// Claude Code has built-in auto-compact that triggers at ~95% context capacity.
|
|
260
|
+
// We improve on this with a 3-layer strategy:
|
|
261
|
+
//
|
|
262
|
+
// Layer 1: CLAUDE.md persistence
|
|
263
|
+
// Channel Mode Instructions are written to the project's CLAUDE.md.
|
|
264
|
+
// CLAUDE.md survives compaction — it's re-read from disk after compact.
|
|
265
|
+
// This means our core instructions are never lost.
|
|
266
|
+
//
|
|
267
|
+
// Layer 2: CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=80
|
|
268
|
+
// Set at session launch to trigger auto-compact at 80% instead of 95%.
|
|
269
|
+
// This gives a comfortable buffer before context pressure causes issues.
|
|
270
|
+
//
|
|
271
|
+
// Layer 3: Proactive /compact
|
|
272
|
+
// After every N messages (configurable, default 50), we proactively
|
|
273
|
+
// send /compact to keep the context lean. This prevents gradual
|
|
274
|
+
// degradation in response quality from context bloat.
|
|
275
|
+
//
|
|
276
|
+
// Session rebuild is only used as a last resort when the session crashes.
|
|
277
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
const CHANNEL_MODE_INSTRUCTIONS = [
|
|
280
|
+
'## Channel Mode (Yuri Gateway)',
|
|
281
|
+
'',
|
|
282
|
+
'You are responding via a messaging channel (Telegram/Feishu), not a terminal.',
|
|
283
|
+
'- Keep responses concise and mobile-friendly.',
|
|
284
|
+
'- Use markdown formatting sparingly (Telegram supports basic markdown).',
|
|
285
|
+
'- If you need to perform operations, do so and report the result.',
|
|
286
|
+
'- At the end of your response, if you observed any memory-worthy signals',
|
|
287
|
+
' (user preferences, priority changes, tech lessons, corrections),',
|
|
288
|
+
' write them to ~/.yuri/inbox.jsonl.',
|
|
289
|
+
'- Update ~/.yuri/focus.yaml and the project\'s focus.yaml after any operation.',
|
|
290
|
+
].join('\n');
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Ensure Channel Mode Instructions exist in the project's CLAUDE.md.
|
|
294
|
+
* This guarantees instructions survive auto-compact (CLAUDE.md is re-read from disk).
|
|
295
|
+
*/
|
|
296
|
+
function ensureClaudeMd(projectRoot) {
|
|
297
|
+
if (!projectRoot) return;
|
|
298
|
+
|
|
299
|
+
const claudeMdPath = path.join(projectRoot, 'CLAUDE.md');
|
|
300
|
+
const marker = '## Channel Mode (Yuri Gateway)';
|
|
301
|
+
|
|
302
|
+
let content = '';
|
|
303
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
304
|
+
content = fs.readFileSync(claudeMdPath, 'utf8');
|
|
305
|
+
if (content.includes(marker)) return; // already present
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Append channel mode instructions
|
|
309
|
+
const separator = content.trim() ? '\n\n' : '';
|
|
310
|
+
fs.writeFileSync(claudeMdPath, content + separator + CHANNEL_MODE_INSTRUCTIONS + '\n');
|
|
311
|
+
console.log(`[claude-tmux] Channel Mode Instructions written to ${claudeMdPath}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Send /compact to Claude Code to proactively free context space.
|
|
316
|
+
* Returns true if compact completed successfully.
|
|
317
|
+
*/
|
|
318
|
+
async function proactiveCompact(name) {
|
|
319
|
+
console.log('[claude-tmux] Proactive /compact triggered');
|
|
320
|
+
injectMessage(name, '/compact focus on the most recent user conversation and any pending operations');
|
|
321
|
+
|
|
322
|
+
const ok = await waitForIdle(name, 120000); // compact can take up to 2min
|
|
323
|
+
if (ok) {
|
|
324
|
+
_messageCount = 0;
|
|
325
|
+
console.log('[claude-tmux] Proactive /compact completed');
|
|
326
|
+
} else {
|
|
327
|
+
console.warn('[claude-tmux] Proactive /compact timed out');
|
|
328
|
+
}
|
|
329
|
+
return ok;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Create a new tmux session and start Claude Code inside it.
|
|
334
|
+
*/
|
|
335
|
+
async function createSession(engineConfig) {
|
|
336
|
+
const sessionName = engineConfig.tmux_session || DEFAULT_SESSION;
|
|
337
|
+
_sessionName = sessionName;
|
|
338
|
+
_sessionReady = false;
|
|
339
|
+
_messageCount = 0;
|
|
340
|
+
|
|
341
|
+
const binary = getClaudeBinary();
|
|
342
|
+
const projectRoot = resolveProjectRoot() || os.homedir();
|
|
343
|
+
|
|
344
|
+
// Ensure CLAUDE.md has channel mode instructions (survives compact)
|
|
345
|
+
ensureClaudeMd(projectRoot);
|
|
346
|
+
|
|
347
|
+
// Kill existing stale session
|
|
348
|
+
if (hasSession(sessionName)) {
|
|
349
|
+
tmuxSafe(`kill-session -t ${sessionName}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Create session with generous scrollback
|
|
353
|
+
tmux(`new-session -d -s ${sessionName} -n claude -c "${projectRoot}"`);
|
|
354
|
+
tmux(`set-option -t ${sessionName} history-limit ${HISTORY_LIMIT}`);
|
|
355
|
+
|
|
356
|
+
// Set auto-compact threshold to 80% (default is 95%)
|
|
357
|
+
// This gives comfortable buffer before context pressure
|
|
358
|
+
const compactPct = engineConfig.autocompact_pct || 80;
|
|
359
|
+
tmux(`send-keys -t ${sessionName}:0 'export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=${compactPct}' Enter`);
|
|
360
|
+
|
|
361
|
+
// Launch Claude Code in interactive mode
|
|
362
|
+
tmux(`send-keys -t ${sessionName}:0 '"${binary}" --dangerously-skip-permissions' Enter`);
|
|
363
|
+
|
|
364
|
+
// Wait for Claude Code to initialize (detect idle indicator)
|
|
365
|
+
const startupTimeout = engineConfig.startup_timeout || 30000;
|
|
366
|
+
const started = await waitForIdle(sessionName, startupTimeout);
|
|
367
|
+
if (!started) {
|
|
368
|
+
throw new Error(`Claude Code did not become idle within ${startupTimeout}ms`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Send L1 context as the initial system message.
|
|
372
|
+
// Channel Mode Instructions are already in CLAUDE.md (survives compact),
|
|
373
|
+
// so we only inject L1 global memory here to prime the session.
|
|
374
|
+
const l1 = loadL1Context();
|
|
375
|
+
if (l1) {
|
|
376
|
+
await injectMessage(sessionName, l1);
|
|
377
|
+
await waitForIdle(sessionName, 120000); // allow up to 2min for L1 processing
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
_sessionReady = true;
|
|
381
|
+
console.log(`[claude-tmux] Session "${sessionName}" ready (cwd: ${projectRoot})`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Wait for Claude Code to become idle.
|
|
386
|
+
* @returns {Promise<boolean>} true if idle detected, false if timeout
|
|
387
|
+
*/
|
|
388
|
+
function waitForIdle(name, timeoutMs) {
|
|
389
|
+
const pollInterval = 2000;
|
|
390
|
+
return new Promise((resolve) => {
|
|
391
|
+
const deadline = Date.now() + timeoutMs;
|
|
392
|
+
const poll = () => {
|
|
393
|
+
if (Date.now() > deadline) {
|
|
394
|
+
return resolve(false);
|
|
395
|
+
}
|
|
396
|
+
if (!hasSession(name)) {
|
|
397
|
+
return resolve(false);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Auto-approve any permission prompts
|
|
401
|
+
if (isApprovalPrompt(name)) {
|
|
402
|
+
tmuxSafe(`send-keys -t ${name}:0 'y' Enter`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (isIdle(name)) {
|
|
406
|
+
return resolve(true);
|
|
407
|
+
}
|
|
408
|
+
setTimeout(poll, pollInterval);
|
|
409
|
+
};
|
|
410
|
+
setTimeout(poll, pollInterval); // initial delay
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Inject a message into the tmux pane via load-buffer (avoids shell escaping issues).
|
|
416
|
+
*/
|
|
417
|
+
function injectMessage(name, text) {
|
|
418
|
+
const tmpFile = path.join(os.tmpdir(), `yuri-tmux-msg-${Date.now()}.txt`);
|
|
419
|
+
fs.writeFileSync(tmpFile, text);
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
tmux(`load-buffer -b yuri-input "${tmpFile}"`);
|
|
423
|
+
tmux(`paste-buffer -b yuri-input -t ${name}:0`);
|
|
424
|
+
tmux(`send-keys -t ${name}:0 Enter`);
|
|
425
|
+
} finally {
|
|
426
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Capture the response after injecting a message.
|
|
432
|
+
*
|
|
433
|
+
* Detection priority (mirrors monitor-agent.sh):
|
|
434
|
+
* P1: Completion message — "[Verb]ed for [N]s/m" (e.g. "Baked for 31s")
|
|
435
|
+
* P2: Idle indicator — ○ appears in pane tail
|
|
436
|
+
* P3: Approval prompt — ◐ detected, auto-approve with 'y'
|
|
437
|
+
* P4: Content stability — 3 consecutive polls with identical MD5 hash
|
|
438
|
+
*/
|
|
439
|
+
async function captureResponse(name, marker, engineConfig) {
|
|
440
|
+
const timeout = engineConfig.timeout || 300000;
|
|
441
|
+
const pollInterval = engineConfig.poll_interval || 2000;
|
|
442
|
+
const stableThreshold = engineConfig.stable_count || 3;
|
|
443
|
+
|
|
444
|
+
const deadline = Date.now() + timeout;
|
|
445
|
+
let lastHash = '';
|
|
446
|
+
let stableCount = 0;
|
|
447
|
+
let sawProcessing = false;
|
|
448
|
+
|
|
449
|
+
return new Promise((resolve) => {
|
|
450
|
+
const poll = () => {
|
|
451
|
+
// Timeout: return whatever we have
|
|
452
|
+
if (Date.now() > deadline) {
|
|
453
|
+
console.warn('[claude-tmux] Response capture timed out');
|
|
454
|
+
const raw = capturePaneRaw(name, 500);
|
|
455
|
+
return resolve(extractResponse(raw, marker));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Session died
|
|
459
|
+
if (!hasSession(name)) {
|
|
460
|
+
return resolve({ reply: '❌ Claude Code session terminated unexpectedly.', raw: '' });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const state = detectState(name);
|
|
464
|
+
const raw = capturePaneRaw(name, 500);
|
|
465
|
+
const hash = crypto.createHash('md5').update(raw).digest('hex');
|
|
466
|
+
|
|
467
|
+
// Track that Claude has started processing (● appeared)
|
|
468
|
+
// This prevents premature completion detection if ○ is still visible
|
|
469
|
+
// from the previous idle state before Claude begins processing.
|
|
470
|
+
if (state === 'processing') {
|
|
471
|
+
sawProcessing = true;
|
|
472
|
+
stableCount = 0;
|
|
473
|
+
lastHash = hash;
|
|
474
|
+
return setTimeout(poll, pollInterval);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// P3: Auto-approve permission prompts (◐)
|
|
478
|
+
if (state === 'approval') {
|
|
479
|
+
tmuxSafe(`send-keys -t ${name}:0 'y' Enter`);
|
|
480
|
+
sawProcessing = true; // approval implies processing started
|
|
481
|
+
stableCount = 0;
|
|
482
|
+
lastHash = hash;
|
|
483
|
+
// Brief pause after approval before next poll
|
|
484
|
+
return setTimeout(poll, 2000);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// P1: Completion message — most reliable done signal
|
|
488
|
+
if (state === 'complete' && sawProcessing) {
|
|
489
|
+
return resolve(extractResponse(raw, marker));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// P2: Idle indicator — done if we saw processing start
|
|
493
|
+
if (state === 'idle' && sawProcessing) {
|
|
494
|
+
return resolve(extractResponse(raw, marker));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// P4: Content stability fallback
|
|
498
|
+
if (hash === lastHash) {
|
|
499
|
+
stableCount++;
|
|
500
|
+
} else {
|
|
501
|
+
stableCount = 0;
|
|
502
|
+
lastHash = hash;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (stableCount >= stableThreshold && sawProcessing) {
|
|
506
|
+
console.log('[claude-tmux] Response detected via content stability');
|
|
507
|
+
return resolve(extractResponse(raw, marker));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
setTimeout(poll, pollInterval);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// Initial delay: give Claude time to start processing
|
|
514
|
+
// before first poll (avoids false-positive idle detection)
|
|
515
|
+
setTimeout(poll, Math.max(pollInterval, 3000));
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Extract the assistant's response from captured pane output.
|
|
521
|
+
* Finds the marker, takes everything after it, strips chrome.
|
|
522
|
+
*/
|
|
523
|
+
function extractResponse(raw, marker) {
|
|
524
|
+
const lines = raw.split('\n');
|
|
525
|
+
let markerIdx = -1;
|
|
526
|
+
|
|
527
|
+
// Find the last occurrence of the marker (in case of scrollback)
|
|
528
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
529
|
+
if (lines[i].includes(marker)) {
|
|
530
|
+
markerIdx = i;
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let responseText;
|
|
536
|
+
if (markerIdx >= 0) {
|
|
537
|
+
// Skip the marker line and any immediate echo of the user message
|
|
538
|
+
const afterMarker = lines.slice(markerIdx + 1).join('\n');
|
|
539
|
+
responseText = stripChrome(afterMarker);
|
|
540
|
+
} else {
|
|
541
|
+
// Fallback: take last chunk of output, strip chrome
|
|
542
|
+
const tail = lines.slice(-100).join('\n');
|
|
543
|
+
responseText = stripChrome(tail);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Trim trailing idle indicators and empty lines
|
|
547
|
+
responseText = responseText
|
|
548
|
+
.replace(/[○●◐◑]\s*$/g, '')
|
|
549
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
550
|
+
.trim();
|
|
551
|
+
|
|
552
|
+
return { reply: responseText || '(no response captured)', raw };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Ensure the tmux session is alive and ready.
|
|
559
|
+
* Lazy-initializes on first call. Restarts if session died.
|
|
560
|
+
*/
|
|
561
|
+
async function ensureSession(engineConfig) {
|
|
562
|
+
if (_sessionName && hasSession(_sessionName) && _sessionReady) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Prevent concurrent initialization
|
|
567
|
+
if (_initPromise) {
|
|
568
|
+
return _initPromise;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const maxRetries = engineConfig.max_retries || 3;
|
|
572
|
+
_initPromise = (async () => {
|
|
573
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
574
|
+
try {
|
|
575
|
+
await createSession(engineConfig);
|
|
576
|
+
return;
|
|
577
|
+
} catch (err) {
|
|
578
|
+
console.error(`[claude-tmux] Session init attempt ${attempt}/${maxRetries} failed: ${err.message}`);
|
|
579
|
+
if (attempt === maxRetries) throw err;
|
|
580
|
+
// Brief pause before retry
|
|
581
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
})();
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
await _initPromise;
|
|
588
|
+
} finally {
|
|
589
|
+
_initPromise = null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Send a message to Claude Code via the persistent tmux session.
|
|
595
|
+
*
|
|
596
|
+
* @param {object} opts
|
|
597
|
+
* @param {string} opts.prompt - User message to send
|
|
598
|
+
* @param {string} opts.cwd - Working directory (used for session init, not per-message)
|
|
599
|
+
* @param {object} opts.engineConfig - Engine configuration
|
|
600
|
+
* @param {number} [opts.timeout=300000] - Timeout in ms
|
|
601
|
+
* @returns {Promise<{reply: string, raw: string}>}
|
|
602
|
+
*/
|
|
603
|
+
async function callClaude(opts) {
|
|
604
|
+
const { prompt, engineConfig, timeout } = opts;
|
|
605
|
+
const config = { ...engineConfig, timeout: timeout || engineConfig.timeout || 300000 };
|
|
606
|
+
|
|
607
|
+
// Queue messages to prevent concurrent injection into the same pane
|
|
608
|
+
return new Promise((resolve, reject) => {
|
|
609
|
+
_messageQueue = _messageQueue.then(async () => {
|
|
610
|
+
try {
|
|
611
|
+
await ensureSession(config);
|
|
612
|
+
|
|
613
|
+
// Layer 3: Proactive compact after N messages
|
|
614
|
+
const compactEvery = config.compact_every || 50;
|
|
615
|
+
if (_messageCount >= compactEvery) {
|
|
616
|
+
await proactiveCompact(_sessionName);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Generate a unique marker for boundary detection
|
|
620
|
+
const marker = `YURI-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
621
|
+
const markedPrompt = `[${marker}] ${prompt}`;
|
|
622
|
+
|
|
623
|
+
// Inject and capture
|
|
624
|
+
injectMessage(_sessionName, markedPrompt);
|
|
625
|
+
const result = await captureResponse(_sessionName, marker, config);
|
|
626
|
+
|
|
627
|
+
_messageCount++;
|
|
628
|
+
resolve(result);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
console.error('[claude-tmux] callClaude error:', err.message);
|
|
631
|
+
|
|
632
|
+
// Mark session as not ready so it gets recreated next time
|
|
633
|
+
_sessionReady = false;
|
|
634
|
+
resolve({ reply: `❌ tmux engine error: ${err.message}`, raw: '' });
|
|
635
|
+
}
|
|
636
|
+
}).catch(reject);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Compose prompt for the persistent session.
|
|
642
|
+
* Only sends the raw user message — the session already has L1 context
|
|
643
|
+
* from initialization, and Claude Code maintains its own conversation history.
|
|
644
|
+
*
|
|
645
|
+
* @param {string} userMessage - The user's message text
|
|
646
|
+
* @param {Array} _chatHistory - Unused (Claude keeps its own context)
|
|
647
|
+
* @returns {string}
|
|
648
|
+
*/
|
|
649
|
+
function composePrompt(userMessage, _chatHistory) {
|
|
650
|
+
return userMessage;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Destroy the tmux session. Called on gateway shutdown.
|
|
655
|
+
*/
|
|
656
|
+
function destroySession() {
|
|
657
|
+
if (_sessionName && hasSession(_sessionName)) {
|
|
658
|
+
tmuxSafe(`kill-session -t ${_sessionName}`);
|
|
659
|
+
console.log(`[claude-tmux] Session "${_sessionName}" destroyed.`);
|
|
660
|
+
}
|
|
661
|
+
_sessionName = null;
|
|
662
|
+
_sessionReady = false;
|
|
663
|
+
_initPromise = null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
module.exports = {
|
|
667
|
+
callClaude,
|
|
668
|
+
composePrompt,
|
|
669
|
+
loadL1Context,
|
|
670
|
+
resolveProjectRoot,
|
|
671
|
+
findClaudeBinary,
|
|
672
|
+
ensureSession,
|
|
673
|
+
destroySession,
|
|
674
|
+
};
|
package/lib/gateway/index.js
CHANGED
package/lib/gateway/router.js
CHANGED
|
@@ -5,9 +5,9 @@ const path = require('path');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const yaml = require('js-yaml');
|
|
7
7
|
|
|
8
|
-
const { callClaude, composePrompt, resolveProjectRoot } = require('./engine/claude-cli');
|
|
9
8
|
const { ChatHistory } = require('./history');
|
|
10
9
|
const { OwnerBinding } = require('./binding');
|
|
10
|
+
const engine = require('./engine/claude-tmux');
|
|
11
11
|
|
|
12
12
|
const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
|
|
13
13
|
|
|
@@ -85,17 +85,17 @@ class Router {
|
|
|
85
85
|
// Claude does not need to "remember" to read these files.
|
|
86
86
|
|
|
87
87
|
// ═══ Resolve project context ═══
|
|
88
|
-
const projectRoot = resolveProjectRoot();
|
|
88
|
+
const projectRoot = engine.resolveProjectRoot();
|
|
89
89
|
|
|
90
90
|
// ═══ Get chat history for conversation continuity ═══
|
|
91
91
|
const chatHistory = this.history.getRecent(msg.chatId);
|
|
92
92
|
|
|
93
93
|
// ═══ Compose prompt: L1 context + chat history + user message ═══
|
|
94
|
-
const prompt = composePrompt(msg.text, chatHistory);
|
|
94
|
+
const prompt = engine.composePrompt(msg.text, chatHistory);
|
|
95
95
|
|
|
96
|
-
// ═══ WORK: Call Claude
|
|
96
|
+
// ═══ WORK: Call Claude engine ═══
|
|
97
97
|
console.log(`[router] Processing: "${msg.text.slice(0, 80)}..." → cwd: ${projectRoot || '~'}`);
|
|
98
|
-
const result = await callClaude({
|
|
98
|
+
const result = await engine.callClaude({
|
|
99
99
|
prompt,
|
|
100
100
|
cwd: projectRoot,
|
|
101
101
|
engineConfig: this.config.engine,
|
|
@@ -218,6 +218,15 @@ class Router {
|
|
|
218
218
|
console.error('[router] Failed to update focus:', err.message);
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Graceful shutdown — destroy persistent engine session if active.
|
|
224
|
+
*/
|
|
225
|
+
async shutdown() {
|
|
226
|
+
if (engine.destroySession) {
|
|
227
|
+
engine.destroySession();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
221
230
|
}
|
|
222
231
|
|
|
223
232
|
module.exports = { Router };
|
package/package.json
CHANGED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { spawn } = 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
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Load L1 (global context) files and compose them into a context block.
|
|
13
|
-
* This is injected into the prompt so Claude does not need to "remember" to read them.
|
|
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
|
-
/**
|
|
40
|
-
* Determine which project the message likely relates to, based on portfolio.
|
|
41
|
-
*/
|
|
42
|
-
function resolveProjectRoot() {
|
|
43
|
-
const registryPath = path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml');
|
|
44
|
-
if (!fs.existsSync(registryPath)) return null;
|
|
45
|
-
|
|
46
|
-
const registry = yaml.load(fs.readFileSync(registryPath, 'utf8')) || {};
|
|
47
|
-
const projects = registry.projects || [];
|
|
48
|
-
const active = projects.filter((p) => p.status === 'active');
|
|
49
|
-
|
|
50
|
-
if (active.length === 0) return null;
|
|
51
|
-
|
|
52
|
-
// Check global focus for active project
|
|
53
|
-
const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
|
|
54
|
-
if (fs.existsSync(focusPath)) {
|
|
55
|
-
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
56
|
-
if (focus.active_project) {
|
|
57
|
-
const match = active.find((p) => p.id === focus.active_project);
|
|
58
|
-
if (match && fs.existsSync(match.root)) return match.root;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Fallback: first active project
|
|
63
|
-
if (active[0] && fs.existsSync(active[0].root)) return active[0].root;
|
|
64
|
-
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Find the claude binary path.
|
|
70
|
-
* Shell aliases (like `cc`) are not available in child_process, so we
|
|
71
|
-
* resolve the actual binary via the user's login shell PATH.
|
|
72
|
-
*/
|
|
73
|
-
function findClaudeBinary() {
|
|
74
|
-
// Primary: resolve via user's login shell (handles all install methods)
|
|
75
|
-
try {
|
|
76
|
-
const resolved = require('child_process')
|
|
77
|
-
.execSync('zsh -lc "which claude" 2>/dev/null', { encoding: 'utf8' })
|
|
78
|
-
.trim();
|
|
79
|
-
if (resolved && fs.existsSync(resolved)) {
|
|
80
|
-
return resolved;
|
|
81
|
-
}
|
|
82
|
-
} catch {
|
|
83
|
-
// fall through
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Fallback: check common install locations
|
|
87
|
-
const candidates = [
|
|
88
|
-
'/usr/local/bin/claude',
|
|
89
|
-
'/opt/homebrew/bin/claude',
|
|
90
|
-
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
|
|
91
|
-
path.join(os.homedir(), '.local', 'bin', 'claude'),
|
|
92
|
-
path.join(os.homedir(), '.claude', 'bin', 'claude'),
|
|
93
|
-
];
|
|
94
|
-
|
|
95
|
-
for (const candidate of candidates) {
|
|
96
|
-
if (fs.existsSync(candidate)) {
|
|
97
|
-
return candidate;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Last resort: let the shell find it
|
|
102
|
-
return 'claude';
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Cache the binary path
|
|
106
|
-
let _claudeBinary = null;
|
|
107
|
-
function getClaudeBinary() {
|
|
108
|
-
if (!_claudeBinary) {
|
|
109
|
-
_claudeBinary = findClaudeBinary();
|
|
110
|
-
console.log(`[claude-cli] Using binary: ${_claudeBinary}`);
|
|
111
|
-
}
|
|
112
|
-
return _claudeBinary;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Execute a Claude CLI call with the Yuri skill.
|
|
117
|
-
*
|
|
118
|
-
* Uses `claude --dangerously-skip-permissions -p "prompt"` to match the user's
|
|
119
|
-
* `cc` alias behavior. The prompt is written to a temp file to avoid command-line
|
|
120
|
-
* length limits and shell escaping issues.
|
|
121
|
-
*
|
|
122
|
-
* @param {object} opts
|
|
123
|
-
* @param {string} opts.prompt - The composed prompt
|
|
124
|
-
* @param {string} opts.cwd - Working directory (project root)
|
|
125
|
-
* @param {object} opts.engineConfig - Engine configuration from channels.yaml
|
|
126
|
-
* @param {number} [opts.timeout=300000] - Timeout in ms (default 5 min)
|
|
127
|
-
* @returns {Promise<{reply: string, raw: string}>}
|
|
128
|
-
*/
|
|
129
|
-
async function callClaude(opts) {
|
|
130
|
-
const { prompt, cwd, engineConfig, timeout = 300000 } = opts;
|
|
131
|
-
|
|
132
|
-
const binary = getClaudeBinary();
|
|
133
|
-
|
|
134
|
-
// Write prompt to temp file to avoid command-line length limits
|
|
135
|
-
const tmpFile = path.join(os.tmpdir(), `yuri-prompt-${Date.now()}.txt`);
|
|
136
|
-
fs.writeFileSync(tmpFile, prompt);
|
|
137
|
-
|
|
138
|
-
return new Promise((resolve) => {
|
|
139
|
-
const args = [
|
|
140
|
-
'--dangerously-skip-permissions',
|
|
141
|
-
'-p',
|
|
142
|
-
`$(cat "${tmpFile}")`,
|
|
143
|
-
];
|
|
144
|
-
|
|
145
|
-
// Use shell to expand $(cat ...) and get proper PATH
|
|
146
|
-
const child = spawn('zsh', ['-lc', `"${binary}" --dangerously-skip-permissions -p "$(cat "${tmpFile}")"`], {
|
|
147
|
-
cwd: cwd || os.homedir(),
|
|
148
|
-
env: { ...process.env },
|
|
149
|
-
timeout,
|
|
150
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
let stdout = '';
|
|
154
|
-
let stderr = '';
|
|
155
|
-
|
|
156
|
-
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
157
|
-
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
158
|
-
|
|
159
|
-
child.on('close', (code) => {
|
|
160
|
-
// Clean up temp file
|
|
161
|
-
try { fs.unlinkSync(tmpFile); } catch {}
|
|
162
|
-
|
|
163
|
-
if (stderr.trim()) {
|
|
164
|
-
console.error('[claude-cli] stderr:', stderr.trim().slice(0, 500));
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (code !== 0 && !stdout.trim()) {
|
|
168
|
-
console.error(`[claude-cli] Process exited with code ${code}`);
|
|
169
|
-
resolve({ reply: `❌ Claude CLI error (exit ${code}). Check gateway logs.`, raw: '' });
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
resolve({ reply: stdout.trim(), raw: stdout });
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
child.on('error', (err) => {
|
|
177
|
-
try { fs.unlinkSync(tmpFile); } catch {}
|
|
178
|
-
console.error('[claude-cli] spawn error:', err.message);
|
|
179
|
-
resolve({ reply: `❌ Failed to start Claude CLI: ${err.message}`, raw: '' });
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Compose the full prompt for a channel message.
|
|
186
|
-
*
|
|
187
|
-
* @param {string} userMessage - The user's message text
|
|
188
|
-
* @param {Array} chatHistory - Recent chat messages [{role, text, ts}]
|
|
189
|
-
* @returns {string}
|
|
190
|
-
*/
|
|
191
|
-
function composePrompt(userMessage, chatHistory) {
|
|
192
|
-
const parts = [];
|
|
193
|
-
|
|
194
|
-
// L1 context (pre-loaded global memory)
|
|
195
|
-
const l1 = loadL1Context();
|
|
196
|
-
if (l1) parts.push(l1);
|
|
197
|
-
|
|
198
|
-
// Chat history for conversation continuity
|
|
199
|
-
if (chatHistory && chatHistory.length > 0) {
|
|
200
|
-
const historyBlock = chatHistory
|
|
201
|
-
.map((m) => `**${m.role}** (${m.ts}): ${m.text}`)
|
|
202
|
-
.join('\n');
|
|
203
|
-
parts.push(`## Recent Conversation\n\n${historyBlock}`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// User message
|
|
207
|
-
parts.push(`## Current Message\n\n${userMessage}`);
|
|
208
|
-
|
|
209
|
-
// Instructions for channel mode
|
|
210
|
-
parts.push(
|
|
211
|
-
`## Channel Mode Instructions\n\n` +
|
|
212
|
-
`You are responding via a messaging channel (Telegram/Feishu), not a terminal.\n` +
|
|
213
|
-
`- Keep responses concise and mobile-friendly.\n` +
|
|
214
|
-
`- Use markdown formatting sparingly (Telegram supports basic markdown).\n` +
|
|
215
|
-
`- If you need to perform operations, do so and report the result.\n` +
|
|
216
|
-
`- At the end of your response, if you observed any memory-worthy signals ` +
|
|
217
|
-
`(user preferences, priority changes, tech lessons, corrections), ` +
|
|
218
|
-
`write them to ~/.yuri/inbox.jsonl.\n` +
|
|
219
|
-
`- Update ~/.yuri/focus.yaml and the project's focus.yaml after any operation.`
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
return parts.join('\n\n---\n\n');
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
module.exports = { callClaude, composePrompt, loadL1Context, resolveProjectRoot, findClaudeBinary };
|