@ruvnet/open-claude-code 2.0.1 → 2.1.1-nightly.20261011.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/package.json +29 -4
  4. package/src/auth/oauth.mjs +46 -6
  5. package/src/config/cli-args.mjs +20 -0
  6. package/src/config/env.mjs +2 -2
  7. package/src/config/settings.mjs +30 -130
  8. package/src/core/agent-loop.mjs +92 -28
  9. package/src/core/providers.mjs +19 -1
  10. package/src/core/session.mjs +34 -149
  11. package/src/core/system-prompt.mjs +43 -47
  12. package/src/hooks/engine.mjs +10 -2
  13. package/src/index.mjs +60 -32
  14. package/src/optimize/cascade.mjs +128 -0
  15. package/src/optimize/commands.mjs +139 -0
  16. package/src/optimize/router.mjs +212 -0
  17. package/src/optimize/store.mjs +170 -0
  18. package/src/permissions/checker.mjs +8 -79
  19. package/src/permissions/injection-check.mjs +3 -2
  20. package/src/permissions/path-check.mjs +8 -0
  21. package/src/permissions/sandbox.mjs +8 -2
  22. package/src/permissions/ssrf-check.mjs +102 -0
  23. package/src/plugins/loader.mjs +14 -6
  24. package/src/skills/loader.mjs +16 -4
  25. package/src/tools/bash.mjs +3 -40
  26. package/src/tools/enter-worktree.mjs +5 -3
  27. package/src/tools/exit-worktree.mjs +4 -3
  28. package/src/tools/grep.mjs +26 -11
  29. package/src/tools/lsp.mjs +23 -9
  30. package/src/tools/read.mjs +20 -9
  31. package/src/tools/send-message.mjs +5 -1
  32. package/src/tools/web-fetch.mjs +15 -1
  33. package/src/tools/web-search.mjs +13 -1
  34. package/src/ui/app.mjs +286 -0
  35. package/src/ui/commands.mjs +121 -71
  36. package/src/ui/components.mjs +219 -0
  37. package/src/ui/ink-app.mjs +13 -52
  38. package/src/ui/markdown.mjs +278 -0
  39. package/src/ui/repl.mjs +10 -25
  40. package/test/test.mjs +0 -2657
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Session Manager — save, resume, list, and teleport sessions.
2
+ * Session Manager — save, resume, and teleport sessions.
3
3
  *
4
4
  * Sessions are stored at ~/.claude/projects/<hash>/session.json
5
5
  * and contain conversation history, token usage, and metadata.
6
- * Supports listing recent sessions and resuming by ID.
7
6
  */
8
7
 
9
8
  import fs from 'fs';
@@ -32,16 +31,8 @@ export class SessionManager {
32
31
  }
33
32
 
34
33
  /**
35
- * Get the base projects directory (for listing all sessions).
36
- */
37
- static getProjectsDir() {
38
- return path.join(os.homedir(), '.claude', 'projects');
39
- }
40
-
41
- /**
42
- * Save the current session state (messages + full state).
34
+ * Save the current session state.
43
35
  * @param {object} state - agent loop state to save
44
- * @returns {string} path to saved session file
45
36
  */
46
37
  save(state) {
47
38
  const dir = this.getSessionDir();
@@ -60,101 +51,28 @@ export class SessionManager {
60
51
  systemPrompt: state.systemPrompt,
61
52
  };
62
53
 
63
- // Save current session
64
54
  const filePath = path.join(dir, 'session.json');
65
55
  fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
66
-
67
- // Also save to sessions history for listing
68
- this._saveToHistory(dir, session);
69
-
70
56
  return filePath;
71
57
  }
72
58
 
73
59
  /**
74
- * Save a minimal entry to sessions history for listing.
75
- */
76
- _saveToHistory(dir, session) {
77
- const historyFile = path.join(dir, 'sessions-history.json');
78
- let history = [];
79
- try {
80
- history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
81
- } catch { /* new file */ }
82
-
83
- // Remove old entry with same ID
84
- history = history.filter(h => h.id !== session.id);
85
-
86
- history.push({
87
- id: session.id,
88
- projectDir: session.projectDir,
89
- startedAt: session.startedAt,
90
- savedAt: session.savedAt,
91
- model: session.model,
92
- turnCount: session.turnCount,
93
- tokenUsage: session.tokenUsage,
94
- messageCount: (session.messages || []).length,
95
- });
96
-
97
- // Keep last 50
98
- if (history.length > 50) history = history.slice(-50);
99
- fs.writeFileSync(historyFile, JSON.stringify(history, null, 2));
100
- }
101
-
102
- /**
103
- * Load and restore a saved session into agent loop state.
104
- * Fully restores messages so conversation can continue.
60
+ * Resume a saved session.
105
61
  * @param {object} state - agent loop state to restore into
106
- * @param {string} [sessionId] - optional specific session ID to load
107
62
  * @returns {boolean} true if session was restored
108
63
  */
109
- resume(state, sessionId) {
64
+ resume(state) {
110
65
  const dir = this.getSessionDir();
111
-
112
- if (sessionId) {
113
- // Look up specific session from history
114
- return this._resumeById(state, dir, sessionId);
115
- }
116
-
117
- // Load most recent session
118
66
  const filePath = path.join(dir, 'session.json');
119
- return this._loadSessionFile(state, filePath);
120
- }
121
-
122
- /**
123
- * Resume a specific session by ID.
124
- */
125
- _resumeById(state, dir, sessionId) {
126
- // Check if it is the current session.json
127
- const mainFile = path.join(dir, 'session.json');
128
- try {
129
- const data = JSON.parse(fs.readFileSync(mainFile, 'utf-8'));
130
- if (data.id === sessionId) {
131
- return this._loadSessionFile(state, mainFile);
132
- }
133
- } catch { /* continue searching */ }
134
67
 
135
- // Check archived sessions
136
- const archiveFile = path.join(dir, `session-${sessionId}.json`);
137
- try {
138
- return this._loadSessionFile(state, archiveFile);
139
- } catch {
140
- return false;
141
- }
142
- }
143
-
144
- /**
145
- * Load a session from a file path.
146
- */
147
- _loadSessionFile(state, filePath) {
148
68
  try {
149
69
  const raw = fs.readFileSync(filePath, 'utf-8');
150
70
  const session = JSON.parse(raw);
151
71
 
152
- // Restore all messages so the conversation continues
153
72
  state.messages = session.messages || [];
154
73
  state.turnCount = session.turnCount || 0;
155
74
  state.tokenUsage = session.tokenUsage || { input: 0, output: 0 };
156
75
  if (session.model) state.model = session.model;
157
- if (session.systemPrompt) state.systemPrompt = session.systemPrompt;
158
76
 
159
77
  this.sessionId = session.id;
160
78
  this.conversationId = session.conversationId;
@@ -166,65 +84,6 @@ export class SessionManager {
166
84
  }
167
85
  }
168
86
 
169
- /**
170
- * List recent sessions for the current project.
171
- * @param {number} [limit=10]
172
- * @returns {Array<{id, projectDir, startedAt, savedAt, model, turnCount, messageCount}>}
173
- */
174
- listSessions(limit = 10) {
175
- const dir = this.getSessionDir();
176
- const historyFile = path.join(dir, 'sessions-history.json');
177
- try {
178
- const history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
179
- return history.slice(-limit).reverse();
180
- } catch {
181
- // Try loading the single session.json as fallback
182
- const mainFile = path.join(dir, 'session.json');
183
- try {
184
- const data = JSON.parse(fs.readFileSync(mainFile, 'utf-8'));
185
- return [{
186
- id: data.id,
187
- projectDir: data.projectDir,
188
- startedAt: data.startedAt,
189
- savedAt: data.savedAt,
190
- model: data.model,
191
- turnCount: data.turnCount,
192
- messageCount: (data.messages || []).length,
193
- }];
194
- } catch {
195
- return [];
196
- }
197
- }
198
- }
199
-
200
- /**
201
- * Check if there is a resumable session for the current project.
202
- * @returns {{ resumable: boolean, session?: object }}
203
- */
204
- checkResumable() {
205
- const dir = this.getSessionDir();
206
- const filePath = path.join(dir, 'session.json');
207
- try {
208
- const raw = fs.readFileSync(filePath, 'utf-8');
209
- const session = JSON.parse(raw);
210
- const age = Date.now() - new Date(session.savedAt).getTime();
211
- // Only resumable if saved within 24h
212
- if (age < 86400000) {
213
- return {
214
- resumable: true,
215
- session: {
216
- id: session.id,
217
- savedAt: session.savedAt,
218
- turnCount: session.turnCount,
219
- messageCount: (session.messages || []).length,
220
- model: session.model,
221
- },
222
- };
223
- }
224
- } catch { /* no session */ }
225
- return { resumable: false };
226
- }
227
-
228
87
  /**
229
88
  * Export session for teleport (transfer between machines).
230
89
  * @param {object} state - agent loop state
@@ -249,10 +108,36 @@ export class SessionManager {
249
108
  * @param {object} state - agent loop state to restore into
250
109
  */
251
110
  importFromTeleport(data, state) {
252
- const session = JSON.parse(Buffer.from(data, 'base64').toString());
253
- state.messages = session.messages || [];
254
- state.turnCount = session.turnCount || 0;
255
- if (session.model) state.model = session.model;
111
+ if (typeof data !== 'string' || data.length === 0) {
112
+ throw new Error('Invalid teleport data: must be a non-empty string');
113
+ }
114
+
115
+ let session;
116
+ try {
117
+ session = JSON.parse(Buffer.from(data, 'base64').toString());
118
+ } catch {
119
+ throw new Error('Invalid teleport data: failed to decode or parse');
120
+ }
121
+
122
+ if (typeof session !== 'object' || session === null) {
123
+ throw new Error('Invalid teleport data: expected an object');
124
+ }
125
+
126
+ // Validate and sanitize messages — must be an array of {role, content}
127
+ const rawMessages = Array.isArray(session.messages) ? session.messages : [];
128
+ state.messages = rawMessages.filter(
129
+ (m) => m && typeof m.role === 'string' && (typeof m.content === 'string' || Array.isArray(m.content))
130
+ );
131
+
132
+ // Validate turn count
133
+ const turnCount = parseInt(session.turnCount, 10);
134
+ state.turnCount = isNaN(turnCount) || turnCount < 0 ? 0 : turnCount;
135
+
136
+ // Only allow known model strings (non-empty string, no shell injection)
137
+ if (typeof session.model === 'string' && /^[\w.:/-]{3,80}$/.test(session.model)) {
138
+ state.model = session.model;
139
+ }
140
+
256
141
  this.sessionId = `sess_teleport_${Date.now()}`;
257
142
  }
258
143
 
@@ -2,9 +2,8 @@
2
2
  * System Prompt Builder — loads and merges CLAUDE.md files.
3
3
  *
4
4
  * Features:
5
- * - Loads CLAUDE.md from: ~/.claude/CLAUDE.md -> parent dirs -> project root -> .claude/CLAUDE.md
6
- * - Supports --add-dir for extra directories
7
- * - Merges in order (global -> parents -> project -> local)
5
+ * - Loads CLAUDE.md from: ~/.claude/CLAUDE.md, project root, parent dirs
6
+ * - Merges in order (global -> project -> local)
8
7
  * - Splits at cache boundary (static prefix cached, dynamic suffix not)
9
8
  * - Includes tool schemas in the system prompt
10
9
  */
@@ -14,56 +13,42 @@ import os from 'os';
14
13
 
15
14
  /**
16
15
  * Load all CLAUDE.md files and merge them in order.
17
- * Walk up parent directories to find all relevant files.
18
16
  * @param {string} [cwd] - current working directory
19
- * @param {string[]} [addDirs] - additional directories to search
20
- * @returns {Array<{source: string, content: string, path?: string}>}
17
+ * @returns {string[]} Array of CLAUDE.md contents in merge order
21
18
  */
22
- export function loadClaudeMdFiles(cwd = process.cwd(), addDirs = []) {
19
+ export function loadClaudeMdFiles(cwd = process.cwd()) {
23
20
  const files = [];
24
- const seen = new Set();
25
-
26
- // Helper to add a file if it exists and hasn't been seen
27
- function tryAdd(filePath, source) {
28
- const resolved = path.resolve(filePath);
29
- if (seen.has(resolved)) return;
30
- if (fs.existsSync(resolved)) {
31
- try {
32
- const content = fs.readFileSync(resolved, 'utf-8');
33
- files.push({ source, content, path: resolved });
34
- seen.add(resolved);
35
- } catch { /* skip unreadable */ }
36
- }
37
- }
38
21
 
39
- // 1. Global: ~/.claude/CLAUDE.md (lowest priority in the project chain)
40
- tryAdd(path.join(os.homedir(), '.claude', 'CLAUDE.md'), 'global');
22
+ // 1. Global: ~/.claude/CLAUDE.md
23
+ const globalPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
24
+ if (fs.existsSync(globalPath)) {
25
+ try {
26
+ files.push({ source: 'global', content: fs.readFileSync(globalPath, 'utf-8') });
27
+ } catch { /* skip */ }
28
+ }
41
29
 
42
- // 2. Walk from root down to cwd collecting CLAUDE.md files
43
- // We walk up first to collect paths, then reverse for parent-first order
44
- const parentPaths = [];
30
+ // 2. Walk from cwd up to root, collecting CLAUDE.md files
31
+ const projectFiles = [];
45
32
  let dir = path.resolve(cwd);
46
33
  const root = path.parse(dir).root;
47
-
48
34
  while (dir !== root) {
49
- parentPaths.push(dir);
35
+ const candidates = [
36
+ path.join(dir, 'CLAUDE.md'),
37
+ path.join(dir, '.claude', 'CLAUDE.md'),
38
+ ];
39
+ for (const f of candidates) {
40
+ if (fs.existsSync(f)) {
41
+ try {
42
+ projectFiles.push({ source: dir, content: fs.readFileSync(f, 'utf-8'), path: f });
43
+ } catch { /* skip */ }
44
+ }
45
+ }
50
46
  dir = path.dirname(dir);
51
47
  }
52
48
 
53
- // Reverse: parent dirs first, project root last (so project overrides parents)
54
- parentPaths.reverse();
55
-
56
- for (const d of parentPaths) {
57
- tryAdd(path.join(d, 'CLAUDE.md'), d);
58
- tryAdd(path.join(d, '.claude', 'CLAUDE.md'), d);
59
- }
60
-
61
- // 3. Additional directories from --add-dir
62
- for (const addDir of addDirs) {
63
- const resolved = path.resolve(addDir);
64
- tryAdd(path.join(resolved, 'CLAUDE.md'), `add-dir:${resolved}`);
65
- tryAdd(path.join(resolved, '.claude', 'CLAUDE.md'), `add-dir:${resolved}`);
66
- }
49
+ // Reverse so parent dirs come first (global -> project -> local)
50
+ projectFiles.reverse();
51
+ files.push(...projectFiles);
67
52
 
68
53
  return files;
69
54
  }
@@ -75,17 +60,29 @@ export function loadClaudeMdFiles(cwd = process.cwd(), addDirs = []) {
75
60
  * @param {Array} [options.tools] - tool definitions for schema inclusion
76
61
  * @param {string} [options.override] - override system prompt entirely
77
62
  * @param {string[]} [options.addDirs] - additional directories to search for CLAUDE.md
78
- * @returns {{ staticPrefix: string, dynamicSuffix: string, full: string, mdFiles: Array }}
63
+ * @returns {{ staticPrefix: string, dynamicSuffix: string, full: string }}
79
64
  */
80
65
  export function buildSystemPrompt({ cwd, tools, override, addDirs } = {}) {
81
66
  if (override) {
82
- return { staticPrefix: override, dynamicSuffix: '', full: override, mdFiles: [] };
67
+ return { staticPrefix: override, dynamicSuffix: '', full: override };
83
68
  }
84
69
 
85
70
  const parts = ['You are an AI coding assistant.'];
86
71
 
87
- // Load CLAUDE.md files (multi-directory merge)
88
- const mdFiles = loadClaudeMdFiles(cwd, addDirs || []);
72
+ // Load CLAUDE.md files
73
+ const mdFiles = loadClaudeMdFiles(cwd);
74
+
75
+ // Add additional directories
76
+ if (addDirs) {
77
+ for (const dir of addDirs) {
78
+ const p = path.join(dir, 'CLAUDE.md');
79
+ if (fs.existsSync(p)) {
80
+ try {
81
+ mdFiles.push({ source: dir, content: fs.readFileSync(p, 'utf-8') });
82
+ } catch { /* skip */ }
83
+ }
84
+ }
85
+ }
89
86
 
90
87
  for (const f of mdFiles) {
91
88
  parts.push(f.content);
@@ -107,7 +104,6 @@ export function buildSystemPrompt({ cwd, tools, override, addDirs } = {}) {
107
104
  staticPrefix,
108
105
  dynamicSuffix,
109
106
  full: staticPrefix + dynamicSuffix,
110
- mdFiles,
111
107
  };
112
108
  }
113
109
 
@@ -121,6 +121,12 @@ export class HookEngine {
121
121
  /**
122
122
  * Execute a single hook. Supports command (shell) and function hooks.
123
123
  *
124
+ * Note: hook.command is an operator-configured shell string (defined in
125
+ * settings.json by the user who controls this machine) — it is intentionally
126
+ * executed as a shell command. The tool input/context is passed ONLY via
127
+ * environment variables (never interpolated into the command string) to
128
+ * prevent escalation from tool input into the hook command itself.
129
+ *
124
130
  * @param {object} hook - hook definition
125
131
  * @param {object} context - execution context
126
132
  * @returns {Promise<object|null>}
@@ -128,10 +134,12 @@ export class HookEngine {
128
134
  async _executeHook(hook, context) {
129
135
  try {
130
136
  if (hook.command) {
137
+ // Ensure env values are strings (guards against prototype pollution
138
+ // or non-string values from context objects breaking child_process).
131
139
  const env = {
132
140
  ...process.env,
133
- HOOK_EVENT: context.event,
134
- HOOK_TOOL: context.toolName || '',
141
+ HOOK_EVENT: String(context.event || ''),
142
+ HOOK_TOOL: String(context.toolName || ''),
135
143
  HOOK_INPUT: JSON.stringify(context.input || {}),
136
144
  };
137
145
  const output = execSync(hook.command, {
package/src/index.mjs CHANGED
@@ -12,17 +12,16 @@
12
12
  * - 6 permission modes + sandbox
13
13
  * - Context compaction + auto-compaction
14
14
  * - Hooks system (7 events)
15
- * - Settings chain (4 sources, 76 properties, deep merge, write-back)
15
+ * - Settings chain (5 layers, 76 properties)
16
16
  * - Multi-provider support (Anthropic, OpenAI, Google)
17
17
  * - Custom agents and skills
18
- * - Session management with save/resume/list
19
- * - File checkpoints with undo
18
+ * - Session management and checkpoints
20
19
  * - Prompt caching
21
- * - 39+ slash commands (all functional)
22
- * - Rich TUI with cost display
20
+ * - 39 slash commands
23
21
  * - Telemetry stub
24
22
  */
25
23
 
24
+ import { readFileSync } from 'node:fs';
26
25
  import { createAgentLoop } from './core/agent-loop.mjs';
27
26
  import { createToolRegistry } from './tools/registry.mjs';
28
27
  import { createPermissionChecker } from './permissions/checker.mjs';
@@ -36,15 +35,33 @@ import { SessionManager } from './core/session.mjs';
36
35
  import { CheckpointManager } from './core/checkpoints.mjs';
37
36
  import { PromptCache } from './core/cache.mjs';
38
37
  import { readEnv } from './config/env.mjs';
39
- import { renderCostInline } from './ui/ink-app.mjs';
40
38
  import * as telemetry from './telemetry/index.mjs';
41
39
 
42
40
  async function main() {
43
- const args = parseArgs(process.argv.slice(2));
41
+ const rawArgv = process.argv.slice(2);
42
+
43
+ // ── Metaharness subcommand dispatch (opt-in; runs before normal flow) ──
44
+ // `occ optimize|redblue|darwin ...` are handled here and exit. A normal
45
+ // `occ "prompt"` never reaches this branch, so default behavior is intact.
46
+ const subcommand = rawArgv[0];
47
+ if (subcommand === 'optimize' || subcommand === 'redblue' || subcommand === 'darwin') {
48
+ const settings = await loadSettings();
49
+ const { runOptimize, delegateToCli } = await import('./optimize/commands.mjs');
50
+ if (subcommand === 'optimize') {
51
+ const { output, code } = await runOptimize(rawArgv.slice(1), settings);
52
+ console.log(output);
53
+ process.exit(code);
54
+ }
55
+ const pkg = subcommand === 'redblue' ? '@metaharness/redblue' : '@metaharness/darwin';
56
+ const { code } = await delegateToCli(pkg, rawArgv.slice(1));
57
+ process.exit(code);
58
+ }
59
+
60
+ const args = parseArgs(rawArgv);
44
61
 
45
62
  // Handle --version
46
63
  if (args.showVersion) {
47
- console.log('open-claude-code v2.0.1');
64
+ console.log('open-claude-code v' + JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
48
65
  process.exit(0);
49
66
  }
50
67
 
@@ -66,12 +83,7 @@ async function main() {
66
83
  if (args.debug) settings.debug = true;
67
84
 
68
85
  const tools = createToolRegistry();
69
-
70
- // In print mode (-p), auto-deny by not passing rl (fail-safe)
71
- const permConfig = { ...settings.permissions };
72
- // rl will be set below for REPL mode
73
-
74
- const permissions = createPermissionChecker(permConfig);
86
+ const permissions = createPermissionChecker(settings.permissions);
75
87
  const hooks = new HookEngine(settings.hooks);
76
88
 
77
89
  // Apply tool allow/deny lists
@@ -115,12 +127,29 @@ async function main() {
115
127
  const mcpResourceTool = tools.get('ReadMcpResource');
116
128
  if (mcpResourceTool) mcpResourceTool._mcpClients = mcpClients;
117
129
 
130
+ // ── Opt-in self-optimization cascade ──
131
+ // Only constructed when enabled (flag > env > setting). When null, the agent
132
+ // loop behaves exactly as before — same model, no outcome recording.
133
+ let cascade = null;
134
+ const { isSelfOptimizeEnabled } = await import('./optimize/cascade.mjs');
135
+ if (isSelfOptimizeEnabled(args, settings, process.env)) {
136
+ const { SelfOptimizeCascade } = await import('./optimize/cascade.mjs');
137
+ cascade = new SelfOptimizeCascade({
138
+ settings,
139
+ fallbackModel: args.model || settings.model || 'claude-sonnet-4-6',
140
+ });
141
+ if (args.verbose || settings.verbose) {
142
+ console.error('\x1b[2m[self-optimize] cost-cascade router enabled\x1b[0m');
143
+ }
144
+ }
145
+
118
146
  const loop = createAgentLoop({
119
147
  model: args.model || settings.model || 'claude-sonnet-4-6',
120
148
  tools,
121
149
  permissions,
122
150
  settings,
123
151
  hooks,
152
+ cascade,
124
153
  });
125
154
 
126
155
  // Attach extra state for commands to access
@@ -135,14 +164,8 @@ async function main() {
135
164
 
136
165
  telemetry.track('session.start', { model: loop.state.model });
137
166
 
138
- // Graceful shutdown — save session before exit
167
+ // Graceful shutdown
139
168
  const cleanup = async () => {
140
- // Auto-save session if there are messages
141
- if (loop.state.messages.length > 0) {
142
- try {
143
- sessionManager.save(loop.state);
144
- } catch { /* best effort */ }
145
- }
146
169
  telemetry.track('session.end', {
147
170
  turns: loop.state.turnCount,
148
171
  tokens: loop.state.tokenUsage,
@@ -155,12 +178,12 @@ async function main() {
155
178
  process.on('SIGTERM', async () => { await cleanup(); process.exit(0); });
156
179
 
157
180
  if (args.prompt) {
158
- // Non-interactive: run prompt and exit
159
- // In print mode, permissions auto-deny (no rl passed)
181
+ // Non-interactive: run prompt and exit (no Ink — plain stdout)
160
182
  const outputFormat = args.outputFormat || 'text';
161
183
  const results = [];
162
184
 
163
185
  for await (const event of loop.run(args.prompt)) {
186
+ if (event.type === 'error') process.exitCode = 1;
164
187
  if (outputFormat === 'json') {
165
188
  results.push(event);
166
189
  } else if (outputFormat === 'stream-json') {
@@ -187,16 +210,21 @@ async function main() {
187
210
 
188
211
  await cleanup();
189
212
  } else {
190
- // Interactive REPL — check for resumable session
191
- const resumable = sessionManager.checkResumable();
192
- if (resumable.resumable) {
193
- const s = resumable.session;
194
- console.log(`\x1b[2mResumable session found: ${s.id} (${s.messageCount} messages, ${s.turnCount} turns)\x1b[0m`);
195
- console.log(`\x1b[2mType /resume to restore, or start fresh.\x1b[0m`);
196
- }
213
+ // Interactive: use Ink React TUI
214
+ try {
215
+ const { startInkApp } = await import('./ui/app.mjs');
216
+ const inkInstance = startInkApp(loop, settings);
197
217
 
198
- const { startRepl } = await import('./ui/repl.mjs');
199
- await startRepl(loop, settings, permissions);
218
+ // Wait for Ink to exit (user pressed Ctrl+C or /quit)
219
+ await inkInstance.waitUntilExit();
220
+ } catch (err) {
221
+ // Fallback to readline REPL if Ink fails (e.g. no TTY, missing deps)
222
+ if (settings.debug) {
223
+ console.error(`Ink UI unavailable (${err.message}), falling back to readline REPL`);
224
+ }
225
+ const { startRepl } = await import('./ui/repl.mjs');
226
+ await startRepl(loop, settings);
227
+ }
200
228
  await cleanup();
201
229
  }
202
230
  }