klyro 0.1.44 → 0.1.46

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/dist/cli/repl.js CHANGED
@@ -17,6 +17,7 @@ import { buildLevel6Context } from '../context/level6.js';
17
17
  import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
18
18
  import { TuiApprovalBridge } from '../tui/approval.js';
19
19
  import { parseUnifiedDiff } from '../tui/diff-parser.js';
20
+ import { parse } from './slash/parser.js';
20
21
  import { resolveProvider, providerHelp } from '../providers.js';
21
22
  import { inferProviderFromBaseURL } from '../agent/registry.js';
22
23
  import { getDefaultSessionStore } from '../persistence/session.js';
@@ -40,7 +41,7 @@ export async function startRepl(opts = {}) {
40
41
  const baseUrl = resolved.baseURL;
41
42
  const apiKey = resolved.apiKey;
42
43
  let model = opts.model ?? resolved.model;
43
- const cwd = opts.cwd ?? process.cwd();
44
+ let cwd = opts.cwd ?? process.cwd();
44
45
  const registry = builtinRegistry();
45
46
  const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
46
47
  const providerKind = inferProviderFromBaseURL(baseUrl);
@@ -60,10 +61,10 @@ export async function startRepl(opts = {}) {
60
61
  : httpChatAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 });
61
62
  let adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
62
63
  const ctxBlock = await buildLevel6Context({ cwd });
63
- const ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
64
- // 4.4 KLYRO.md hierarchy
64
+ let ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
65
+ // 4.4 KLYRO.md hierarchy (mutable — /reload refreshes)
65
66
  const klyroMd = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
66
- const klyroBlock = klyroMd ? `\n\n<KLYRO.md>\n${klyroMd.slice(0, 4000)}\n</KLYRO.md>` : '';
67
+ let klyroBlock = klyroMd ? `\n\n<KLYRO.md>\n${klyroMd.slice(0, 4000)}\n</KLYRO.md>` : '';
67
68
  // 2.3 layered system prompt
68
69
  const systemPromptFn = (_ctx) => {
69
70
  const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix + klyroBlock });
@@ -84,7 +85,25 @@ export async function startRepl(opts = {}) {
84
85
  const pendingQueue = [];
85
86
  let isMounted = false;
86
87
  let directHooks;
88
+ // Plain-text mirror for exit replay (scroll.md §1.2: session survives in
89
+ // native scrollback after the alt screen is torn down). Cap 300 lines.
90
+ const exitMirror = [];
91
+ function mirrorLine(item) {
92
+ let line = null;
93
+ if (item.kind === 'text')
94
+ line = `${item.role === 'user' ? '> ' : ''}${item.text}`;
95
+ else if (item.kind === 'error')
96
+ line = `[error] ${item.message}`;
97
+ else if (item.kind === 'file_changed')
98
+ line = `[${item.op}] ${item.path}`;
99
+ if (line === null)
100
+ return;
101
+ exitMirror.push(line.slice(0, 2000));
102
+ if (exitMirror.length > 300)
103
+ exitMirror.splice(0, exitMirror.length - 300);
104
+ }
87
105
  function queuedAppend(item) {
106
+ mirrorLine(item);
88
107
  if (isMounted && directHooks)
89
108
  directHooks.append(item);
90
109
  else
@@ -139,6 +158,49 @@ export async function startRepl(opts = {}) {
139
158
  let tuiSessionId;
140
159
  if (isAltScreen)
141
160
  enterAlt();
161
+ // I7 (scroll.md §8.6, S8): while the TUI owns stdout, route console.*
162
+ // to a ring buffer + ~/.klyro/debug.log so stray tool/provider logs
163
+ // can't corrupt the frame. Restored on exit.
164
+ const consoleRing = [];
165
+ const origConsoleFns = {
166
+ log: console.log,
167
+ info: console.info,
168
+ warn: console.warn,
169
+ error: console.error,
170
+ debug: console.debug,
171
+ };
172
+ function patchConsole() {
173
+ if (!isAltScreen)
174
+ return;
175
+ const sink = (...args) => {
176
+ const line = `[${new Date().toISOString()}] ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`;
177
+ consoleRing.push(line);
178
+ if (consoleRing.length > 200)
179
+ consoleRing.splice(0, consoleRing.length - 200);
180
+ try {
181
+ const fs = require('node:fs');
182
+ const path = require('node:path');
183
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
184
+ const dir = path.join(home, '.klyro');
185
+ fs.mkdirSync(dir, { recursive: true });
186
+ fs.appendFileSync(path.join(dir, 'debug.log'), line + '\n');
187
+ }
188
+ catch { /* ignore */ }
189
+ };
190
+ console.log = sink;
191
+ console.info = sink;
192
+ console.warn = sink;
193
+ console.error = sink;
194
+ console.debug = sink;
195
+ }
196
+ function restoreConsole() {
197
+ console.log = origConsoleFns.log;
198
+ console.info = origConsoleFns.info;
199
+ console.warn = origConsoleFns.warn;
200
+ console.error = origConsoleFns.error;
201
+ console.debug = origConsoleFns.debug;
202
+ }
203
+ patchConsole();
142
204
  const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
143
205
  // P1 session/permission state (commands.md Priority 1)
144
206
  let sessionLabel = '';
@@ -146,6 +208,85 @@ export async function startRepl(opts = {}) {
146
208
  let fastMode = false;
147
209
  let displayMode = 'default';
148
210
  let lastAssistantText = '';
211
+ // P2 state (commands.md Priority 2)
212
+ let activeAgent = 'default';
213
+ let verboseMode = false;
214
+ let detailsMode = false;
215
+ let rawMode = false;
216
+ let lastPromptText = '';
217
+ const attachedFiles = new Map();
218
+ const bgAgentTasks = [];
219
+ const aliases = new Map();
220
+ const savedPrompts = new Map();
221
+ const AGENT_ROLES = ['default', 'explorer', 'implementer', 'tester', 'reviewer'];
222
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
223
+ const aliasFile = `${homeDir}/.klyro/aliases.json`.replace(/\\/g, '/');
224
+ const promptFile = `${homeDir}/.klyro/prompts.json`.replace(/\\/g, '/');
225
+ try {
226
+ const { readFileSync, existsSync } = await import('node:fs');
227
+ if (existsSync(aliasFile)) {
228
+ const raw = JSON.parse(readFileSync(aliasFile, 'utf-8'));
229
+ for (const [k, v] of Object.entries(raw))
230
+ aliases.set(k, v);
231
+ }
232
+ if (existsSync(promptFile)) {
233
+ const raw = JSON.parse(readFileSync(promptFile, 'utf-8'));
234
+ for (const [k, v] of Object.entries(raw))
235
+ savedPrompts.set(k, v);
236
+ }
237
+ }
238
+ catch { /* best-effort */ }
239
+ function persistMap(file, m) {
240
+ try {
241
+ const fs = require('node:fs');
242
+ const path = require('node:path');
243
+ fs.mkdirSync(path.dirname(file), { recursive: true });
244
+ fs.writeFileSync(file, JSON.stringify(Object.fromEntries(m), null, 2), 'utf-8');
245
+ }
246
+ catch { /* best-effort */ }
247
+ }
248
+ /** Truncation budget honoring /verbose and /raw. */
249
+ function outCap(s) {
250
+ const cap = rawMode ? 12000 : verboseMode ? 8000 : 4000;
251
+ return s.length > cap ? s.slice(0, cap) + `\n... [truncated ${s.length - cap} chars]` : s;
252
+ }
253
+ async function execShell(command, timeoutMs = 120_000) {
254
+ const r = await registry.execute('shell_exec', { command, timeoutMs }, { cwd, env: process.env, nonInteractive: true });
255
+ if (!r.ok)
256
+ throw new Error(r.error.message ?? 'shell failed');
257
+ return r.value;
258
+ }
259
+ /** Read-only LLM answer (no tools) — powers /ask and /explain. */
260
+ async function answerReadOnly(question, context) {
261
+ const sys = systemPromptFn({ cwd, telemetry: '' }) + '\n\nAnswer read-only: do not call tools, do not edit files.';
262
+ const userText = context ? `${question}\n\n<context>\n${context.slice(0, 6000)}\n</context>` : question;
263
+ const req = {
264
+ model,
265
+ system: sys,
266
+ messages: [{ role: 'user', content: [{ kind: 'text', text: userText }] }],
267
+ tools: [],
268
+ signal: ac.signal,
269
+ };
270
+ queuedStatus({ status: 'running', step: 0, model });
271
+ let text = '';
272
+ try {
273
+ for await (const ev of adapter.stream(req)) {
274
+ if (ev.kind === 'text_delta') {
275
+ text += ev.text;
276
+ queuedDelta(ev.text);
277
+ }
278
+ else if (ev.kind === 'error')
279
+ throw new Error(ev.message);
280
+ }
281
+ lastAssistantText = text;
282
+ queuedStatus({ status: 'done' });
283
+ }
284
+ catch (err) {
285
+ const msg = err instanceof Error ? err.message : String(err);
286
+ queuedAppend({ id: `ro-err-${Date.now()}`, kind: 'error', message: msg });
287
+ queuedStatus({ status: 'error', errorMessage: msg });
288
+ }
289
+ }
149
290
  function queuedClear() {
150
291
  if (isMounted && directHooks)
151
292
  directHooks.clearTranscript();
@@ -162,6 +303,7 @@ export async function startRepl(opts = {}) {
162
303
  approvalBridge: tuiBridge,
163
304
  isFullscreen: isAltScreen,
164
305
  onPrompt: async (text) => {
306
+ lastPromptText = text;
165
307
  inflight = runWithBridge(text);
166
308
  await inflight;
167
309
  inflight = null;
@@ -197,6 +339,7 @@ export async function startRepl(opts = {}) {
197
339
  leaveAlt();
198
340
  };
199
341
  process.once('SIGINT', sigintHandler);
342
+ process.once('SIGTERM', sigintHandler);
200
343
  async function runWithBridge(text) {
201
344
  if (!model) {
202
345
  queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message: 'no model configured' });
@@ -422,7 +565,11 @@ export async function startRepl(opts = {}) {
422
565
  ' project: /init /status /context /diff /plan [task] /todos /memory',
423
566
  ' perms: /permissions /mode [m] /sandbox [dir] /approve /deny',
424
567
  ' app: /login /logout /auth /version /update /cancel /shell (!cmd) /mention (@path) /tools /config /doctor',
425
- ' (!cmd runs shell, @path attaches a file)',
568
+ ' P2: /review /code-review /security-review /simplify /test /lint /build /run /fix /explain /format /ask',
569
+ ' /undo /redo /rewind /checkpoint /accept /reject /details /verbose /raw /activity /tasks /queue /retry',
570
+ ' /mcp /agents /subtask /background /attach /files /ls /tree /search /web /read /map /tokens',
571
+ ' /commit /push /pull /pr /issue /theme /debug /whoami /reload /reset /prompt /alias /commands /env /deps /install',
572
+ ' (!cmd runs shell, @path attaches a file; /commands lists everything)',
426
573
  `provider: ${currentProvider} model: ${model} effort: ${effortLevel}${fastMode ? ' fast' : ''} (${currentMaxSteps} steps) mode: ${displayMode} cwd: ${cwd}${sessionLabel ? ` session: ${sessionLabel}` : ''}${currentBranch ? ` branch: ${currentBranch}` : ''}`,
427
574
  ].join('\n');
428
575
  queuedAppend({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
@@ -498,7 +645,7 @@ export async function startRepl(opts = {}) {
498
645
  });
499
646
  }
500
647
  else {
501
- queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${currentProvider} effort: ${effortLevel} (${currentMaxSteps} steps) cwd: ${cwd}`, role: 'assistant' });
648
+ queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${currentProvider} effort: ${effortLevel}${fastMode ? '+fast' : ''} (${currentMaxSteps} steps) mode: ${displayMode} agent: ${activeAgent} cwd: ${cwd}${sessionLabel ? ` session: ${sessionLabel}` : ''}${currentBranch ? ` branch: ${currentBranch}` : ''}`, role: 'assistant' });
502
649
  }
503
650
  return;
504
651
  }
@@ -1018,26 +1165,1011 @@ export async function startRepl(opts = {}) {
1018
1165
  queuedAppend({ id: `set-${Date.now()}`, kind: 'text', text: `config: ${getConfigPath()} (alias of /config)`, role: 'assistant' });
1019
1166
  return;
1020
1167
  }
1168
+ case 'review': {
1169
+ try {
1170
+ const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
1171
+ if (!r.ok) {
1172
+ queuedAppend({ id: `rev-err-${Date.now()}`, kind: 'error', message: `review failed: ${r.error.message ?? 'git_diff error'}` });
1173
+ }
1174
+ else {
1175
+ const v = r.value;
1176
+ const head = v.patchedFiles.length === 0 ? 'Working tree clean — nothing to review.' : `Reviewing ${v.patchedFiles.length} file(s): ${v.patchedFiles.join(', ')}`;
1177
+ let extra = '';
1178
+ if (cmd.target) {
1179
+ try {
1180
+ const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
1181
+ if (fr.ok)
1182
+ extra = `\n\n--- ${cmd.target} ---\n${String(fr.value.content ?? '').slice(0, 2000)}`;
1183
+ }
1184
+ catch { /* ignore */ }
1185
+ }
1186
+ queuedAppend({ id: `rev-${Date.now()}`, kind: 'text', text: `${head}\n${v.stat.slice(0, 1500)}${extra}\n\n${outCap(v.diff).slice(0, 3000)}`, role: 'assistant' });
1187
+ }
1188
+ }
1189
+ catch (err) {
1190
+ queuedAppend({ id: `rev-err2-${Date.now()}`, kind: 'error', message: `review failed: ${err instanceof Error ? err.message : String(err)}` });
1191
+ }
1192
+ return;
1193
+ }
1194
+ case 'code-review': {
1195
+ try {
1196
+ const { checkImports } = await import('../verification/scoped.js');
1197
+ const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
1198
+ if (!r.ok) {
1199
+ queuedAppend({ id: `cr-err-${Date.now()}`, kind: 'error', message: 'code-review failed: no diff available' });
1200
+ }
1201
+ else {
1202
+ const v = r.value;
1203
+ const findings = [];
1204
+ for (const f of v.patchedFiles.slice(0, 10)) {
1205
+ const ic = checkImports(cwd, f);
1206
+ for (const m of ic.missing)
1207
+ findings.push(`missing import '${m}' in ${f}`);
1208
+ if (f.length > 200)
1209
+ findings.push(`suspicious path length: ${f}`);
1210
+ }
1211
+ if (/(^|\/)\.env(\.|$)/.test(v.diff))
1212
+ findings.push('.env content in diff — never commit secrets');
1213
+ if (/console\.log|debugger/.test(v.diff))
1214
+ findings.push('debug leftovers (console.log/debugger) in diff');
1215
+ if (/\.skip\(|\.todo\(|xit\(|xtest\(/.test(v.diff))
1216
+ findings.push('skipped tests in diff');
1217
+ const verdict = findings.length === 0 ? 'No issues found.' : `Findings (${findings.length}):\n- ${findings.join('\n- ')}`;
1218
+ queuedAppend({ id: `cr-${Date.now()}`, kind: 'text', text: `Code review — ${v.patchedFiles.length} file(s)${cmd.options ? ` (${cmd.options})` : ''}:\n${v.stat.slice(0, 1000)}\n\n${verdict}`, role: 'assistant' });
1219
+ }
1220
+ }
1221
+ catch (err) {
1222
+ queuedAppend({ id: `cr-err2-${Date.now()}`, kind: 'error', message: `code-review failed: ${err instanceof Error ? err.message : String(err)}` });
1223
+ }
1224
+ return;
1225
+ }
1226
+ case 'security-review': {
1227
+ try {
1228
+ const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
1229
+ const diff = r.ok ? r.value.diff : '';
1230
+ const checks = [
1231
+ [/sk-[A-Za-z0-9]{8,}|sk-ant-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{10,}/, 'possible hardcoded secret in diff'],
1232
+ [/(^|\/)\.env(\.|$)/, '.env content in diff'],
1233
+ [/\beval\s*\(/, 'eval() usage in diff'],
1234
+ [/rm\s+-rf\s+(\/|~|\*)/, 'dangerous rm -rf in diff'],
1235
+ [/chmod\s+-R\s+777/, 'chmod 777 in diff'],
1236
+ [/curl.*\|\s*(sh|bash)/i, 'curl|sh pipe in diff'],
1237
+ [/password\s*=\s*["'][^"']+["']/i, 'hardcoded password in diff'],
1238
+ ];
1239
+ const hits = checks.filter(([re]) => re.test(diff)).map(([, msg]) => msg);
1240
+ queuedAppend({ id: `sr-${Date.now()}`, kind: 'text', text: hits.length === 0 ? 'Security review: no issues found in working-tree diff.' : `Security review findings:\n- ${hits.join('\n- ')}`, role: 'assistant' });
1241
+ }
1242
+ catch (err) {
1243
+ queuedAppend({ id: `sr-err-${Date.now()}`, kind: 'error', message: `security-review failed: ${err instanceof Error ? err.message : String(err)}` });
1244
+ }
1245
+ return;
1246
+ }
1247
+ case 'simplify': {
1248
+ await runWithBridge(`Simplify ${cmd.target ?? 'recent changes'}: refactor for clarity without changing behavior. Keep the diff minimal.`);
1249
+ return;
1250
+ }
1251
+ case 'test': {
1252
+ try {
1253
+ const { primaryVerifyCommand } = await import('../verification/registry.js');
1254
+ const { buildScopedCommand } = await import('../verification/scoped.js');
1255
+ const { verify } = await import('../verification/engine.js');
1256
+ const base = primaryVerifyCommand(cwd);
1257
+ if (!base) {
1258
+ queuedAppend({ id: `tst-${Date.now()}`, kind: 'text', text: 'No test command detected. Try /verify or run tests manually.', role: 'assistant' });
1259
+ }
1260
+ else {
1261
+ const scoped = cmd.target ? buildScopedCommand(cwd, base, [cmd.target]) ?? base : base;
1262
+ queuedAppend({ id: `tst-run-${Date.now()}`, kind: 'text', text: `[test] running \`${scoped}\`...`, role: 'assistant' });
1263
+ const res = await verify({ cwd, command: scoped, timeoutMs: 120_000 });
1264
+ queuedAppend({ id: `tst-res-${Date.now()}`, kind: 'text', text: res.ok ? `[test] passed (${scoped})` : `[test] failed:\n${outCap(res.stderr || res.stdout)}`, role: 'assistant' });
1265
+ }
1266
+ }
1267
+ catch (err) {
1268
+ queuedAppend({ id: `tst-err-${Date.now()}`, kind: 'error', message: `test failed: ${err instanceof Error ? err.message : String(err)}` });
1269
+ }
1270
+ return;
1271
+ }
1272
+ case 'lint': {
1273
+ try {
1274
+ const { readFileSync, existsSync } = await import('node:fs');
1275
+ const { join } = await import('node:path');
1276
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
1277
+ let lintCmd = null;
1278
+ if (pkg.scripts?.lint)
1279
+ lintCmd = 'npm run lint --silent';
1280
+ else if (existsSync(join(cwd, 'eslint.config.js')) || existsSync(join(cwd, '.eslintrc.json')))
1281
+ lintCmd = 'npx eslint .';
1282
+ else
1283
+ lintCmd = 'npx tsc --noEmit';
1284
+ queuedAppend({ id: `lint-run-${Date.now()}`, kind: 'text', text: `[lint] running \`${lintCmd}\`...`, role: 'assistant' });
1285
+ const v = await execShell(lintCmd);
1286
+ queuedAppend({ id: `lint-res-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `[lint] clean (${lintCmd})` : `[lint] issues (exit ${v.exitCode}):\n${outCap(v.stdout + v.stderr)}`, role: 'assistant' });
1287
+ }
1288
+ catch (err) {
1289
+ queuedAppend({ id: `lint-err-${Date.now()}`, kind: 'error', message: `lint failed: ${err instanceof Error ? err.message : String(err)}` });
1290
+ }
1291
+ return;
1292
+ }
1293
+ case 'build': {
1294
+ try {
1295
+ const { readFileSync } = await import('node:fs');
1296
+ const { join } = await import('node:path');
1297
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
1298
+ if (!pkg.scripts?.build) {
1299
+ queuedAppend({ id: `bld-${Date.now()}`, kind: 'text', text: 'No build script in package.json.', role: 'assistant' });
1300
+ }
1301
+ else {
1302
+ queuedAppend({ id: `bld-run-${Date.now()}`, kind: 'text', text: '[build] running `npm run build`...', role: 'assistant' });
1303
+ const v = await execShell('npm run build', 300_000);
1304
+ queuedAppend({ id: `bld-res-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? '[build] succeeded' : `[build] failed (exit ${v.exitCode}):\n${outCap(v.stdout + v.stderr)}`, role: 'assistant' });
1305
+ }
1306
+ }
1307
+ catch (err) {
1308
+ queuedAppend({ id: `bld-err-${Date.now()}`, kind: 'error', message: `build failed: ${err instanceof Error ? err.message : String(err)}` });
1309
+ }
1310
+ return;
1311
+ }
1312
+ case 'run': {
1313
+ if (!cmd.command) {
1314
+ queuedAppend({ id: `run-${Date.now()}`, kind: 'text', text: 'usage: /run <command>', role: 'assistant' });
1315
+ }
1316
+ else {
1317
+ try {
1318
+ const v = await execShell(cmd.command, 300_000);
1319
+ queuedAppend({ id: `run2-${Date.now()}`, kind: 'text', text: `$ ${cmd.command}\nexit ${v.exitCode}\n${outCap(v.stdout + (v.stderr ? `\n[stderr]\n${v.stderr}` : '') || '(no output)')}`, role: 'assistant' });
1320
+ }
1321
+ catch (err) {
1322
+ queuedAppend({ id: `run-err-${Date.now()}`, kind: 'error', message: `run failed: ${err instanceof Error ? err.message : String(err)}` });
1323
+ }
1324
+ }
1325
+ return;
1326
+ }
1327
+ case 'fix': {
1328
+ await runWithBridge(`Fix ${cmd.target ?? 'failing tests and lint errors'}: reproduce the failure, fix the source (do not edit test assertions unless the test itself is wrong), then verify.`);
1329
+ return;
1330
+ }
1331
+ case 'explain': {
1332
+ if (!cmd.target) {
1333
+ queuedAppend({ id: `exp-${Date.now()}`, kind: 'text', text: 'usage: /explain <file|symbol>', role: 'assistant' });
1334
+ }
1335
+ else {
1336
+ let ctx = '';
1337
+ try {
1338
+ const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
1339
+ if (fr.ok)
1340
+ ctx = `File ${cmd.target}:\n${String(fr.value.content ?? '').slice(0, 6000)}`;
1341
+ }
1342
+ catch { /* symbol — answer without file context */ }
1343
+ await answerReadOnly(`Explain ${cmd.target}: what it does, key logic, and gotchas.`, ctx || undefined);
1344
+ }
1345
+ return;
1346
+ }
1347
+ case 'format': {
1348
+ try {
1349
+ const v = await execShell('git status --porcelain');
1350
+ const files = v.stdout.split('\n').map((l) => l.slice(3).trim()).filter((f) => /\.(ts|tsx|js|jsx|json|md)$/.test(f)).slice(0, 20);
1351
+ if (files.length === 0) {
1352
+ queuedAppend({ id: `fmt-${Date.now()}`, kind: 'text', text: 'Nothing to format (no changed source files).', role: 'assistant' });
1353
+ }
1354
+ else {
1355
+ const check = await execShell('npx --no-install prettier --version').catch(() => null);
1356
+ if (!check || check.exitCode !== 0) {
1357
+ queuedAppend({ id: `fmt2-${Date.now()}`, kind: 'text', text: 'prettier not installed — run `npm i -D prettier` first.', role: 'assistant' });
1358
+ }
1359
+ else {
1360
+ const fv = await execShell(`npx prettier --write ${files.map((f) => `"${f}"`).join(' ')}`);
1361
+ queuedAppend({ id: `fmt3-${Date.now()}`, kind: 'text', text: fv.exitCode === 0 ? `[format] formatted ${files.length} file(s)` : `[format] failed:\n${outCap(fv.stderr || fv.stdout)}`, role: 'assistant' });
1362
+ }
1363
+ }
1364
+ }
1365
+ catch (err) {
1366
+ queuedAppend({ id: `fmt-err-${Date.now()}`, kind: 'error', message: `format failed: ${err instanceof Error ? err.message : String(err)}` });
1367
+ }
1368
+ return;
1369
+ }
1370
+ case 'ask': {
1371
+ if (!cmd.question) {
1372
+ queuedAppend({ id: `ask-${Date.now()}`, kind: 'text', text: 'usage: /ask <question> (read-only, no edits)', role: 'assistant' });
1373
+ }
1374
+ else {
1375
+ await answerReadOnly(cmd.question);
1376
+ }
1377
+ return;
1378
+ }
1379
+ case 'redo': {
1380
+ queuedAppend({ id: `redo-${Date.now()}`, kind: 'text', text: 'No redo stack — checkpoints support /undo and /rewind only.', role: 'assistant' });
1381
+ return;
1382
+ }
1383
+ case 'checkpoint': {
1384
+ try {
1385
+ const { snapshot } = await import('../checkpoints/store.js');
1386
+ const v = await execShell('git status --porcelain');
1387
+ const files = v.stdout.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
1388
+ if (files.length === 0) {
1389
+ queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: 'Working tree clean — nothing to checkpoint.', role: 'assistant' });
1390
+ }
1391
+ else {
1392
+ const id = await snapshot(cwd, files.slice(0, 50));
1393
+ queuedAppend({ id: `ckpt2-${Date.now()}`, kind: 'text', text: `checkpoint ${String(id).slice(0, 8)} — ${files.length} file(s)`, role: 'assistant' });
1394
+ }
1395
+ }
1396
+ catch (err) {
1397
+ queuedAppend({ id: `ckpt-err-${Date.now()}`, kind: 'error', message: `checkpoint failed: ${err instanceof Error ? err.message : String(err)}` });
1398
+ }
1399
+ return;
1400
+ }
1401
+ case 'accept': {
1402
+ const ok = tuiBridge.resolve('allow');
1403
+ queuedAppend({ id: `acc-${Date.now()}`, kind: 'text', text: ok ? 'accepted pending edits' : 'no pending edits to accept', role: 'assistant' });
1404
+ return;
1405
+ }
1406
+ case 'reject': {
1407
+ const ok = tuiBridge.resolve('deny');
1408
+ queuedAppend({ id: `rej-${Date.now()}`, kind: 'text', text: ok ? 'rejected pending edits' : 'no pending edits to reject', role: 'assistant' });
1409
+ return;
1410
+ }
1411
+ case 'details': {
1412
+ detailsMode = !detailsMode;
1413
+ queuedAppend({ id: `det-${Date.now()}`, kind: 'text', text: `detailed activity: ${detailsMode ? 'on (tool groups expanded by default — use ctrl+o)' : 'off'}`, role: 'assistant' });
1414
+ return;
1415
+ }
1416
+ case 'verbose': {
1417
+ verboseMode = !verboseMode;
1418
+ queuedAppend({ id: `verb-${Date.now()}`, kind: 'text', text: `verbose output: ${verboseMode ? 'on (8k output cap)' : 'off (4k output cap)'}`, role: 'assistant' });
1419
+ return;
1420
+ }
1421
+ case 'raw': {
1422
+ rawMode = !rawMode;
1423
+ queuedAppend({ id: `raw-${Date.now()}`, kind: 'text', text: `raw output: ${rawMode ? 'on (12k cap, no truncation notes)' : 'off'}`, role: 'assistant' });
1424
+ return;
1425
+ }
1426
+ case 'activity': {
1427
+ const s = lastStatus;
1428
+ const line = s ? `status=${s.status} step=${s.step}/${s.maxSteps} model=${s.model} tokens=${s.usageInput + s.usageOutput}` : `status=idle model=${model}`;
1429
+ queuedAppend({ id: `act-${Date.now()}`, kind: 'text', text: `Activity: ${line}${inflight ? ' (task running)' : ''}${tuiSessionId ? ` session=${tuiSessionId.slice(0, 8)}` : ''}`, role: 'assistant' });
1430
+ return;
1431
+ }
1432
+ case 'tasks':
1433
+ case 'ps': {
1434
+ const { listJobs } = await import('../tools/shell/background.js');
1435
+ const jobs = listJobs();
1436
+ if (jobs.length === 0)
1437
+ queuedAppend({ id: `tasks-${Date.now()}`, kind: 'text', text: 'No background jobs', role: 'assistant' });
1438
+ else
1439
+ queuedAppend({ id: `tasks2-${Date.now()}`, kind: 'text', text: `Background jobs:\n${jobs.map((j) => ` ${j.id.slice(0, 12)} [${j.running ? 'running' : 'done'}] ${j.command}`).join('\n')}\nstop via /stop <id>`, role: 'assistant' });
1440
+ if (bgAgentTasks.length > 0) {
1441
+ queuedAppend({ id: `tasks3-${Date.now()}`, kind: 'text', text: `Agent tasks:\n${bgAgentTasks.map((t) => ` ${t.id} [${t.status}] ${t.task.slice(0, 80)}`).join('\n')}`, role: 'assistant' });
1442
+ }
1443
+ return;
1444
+ }
1445
+ case 'stop':
1446
+ case 'kill': {
1447
+ const id = cmd.id?.trim();
1448
+ const { listJobs, killJob } = await import('../tools/shell/background.js');
1449
+ if (!id) {
1450
+ const jobs = listJobs();
1451
+ queuedAppend({ id: `stop-${Date.now()}`, kind: 'text', text: jobs.length === 0 ? 'No background jobs.\nusage: /stop <id>' : `usage: /stop <id>\njobs:\n${jobs.map((j) => ` ${j.id.slice(0, 12)} ${j.command}`).join('\n')}`, role: 'assistant' });
1452
+ }
1453
+ else {
1454
+ const match = listJobs().find((j) => j.id.startsWith(id)) ?? listJobs().find((j) => j.id.includes(id));
1455
+ const ag = bgAgentTasks.find((t) => t.id.startsWith(id));
1456
+ if (match) {
1457
+ try {
1458
+ killJob(match.id);
1459
+ queuedAppend({ id: `stop2-${Date.now()}`, kind: 'text', text: `stopped ${match.id.slice(0, 12)}`, role: 'assistant' });
1460
+ }
1461
+ catch (err) {
1462
+ queuedAppend({ id: `stop-err-${Date.now()}`, kind: 'error', message: String(err) });
1463
+ }
1464
+ }
1465
+ else if (ag) {
1466
+ ag.status = 'stopped';
1467
+ queuedAppend({ id: `stop3-${Date.now()}`, kind: 'text', text: `marked agent task ${ag.id} stopped (in-flight loop finishes current step)`, role: 'assistant' });
1468
+ }
1469
+ else {
1470
+ queuedAppend({ id: `stop-err2-${Date.now()}`, kind: 'error', message: `no job: ${id}` });
1471
+ }
1472
+ }
1473
+ return;
1474
+ }
1475
+ case 'queue': {
1476
+ const pending = bgAgentTasks.filter((t) => t.status === 'running');
1477
+ queuedAppend({ id: `queue-${Date.now()}`, kind: 'text', text: pending.length === 0 ? 'Queue empty (input queue lives in the TUI — enter to queue while running, esc to drop).' : `Queued/running agent tasks:\n${pending.map((t) => ` ${t.id}: ${t.task.slice(0, 80)}`).join('\n')}`, role: 'assistant' });
1478
+ return;
1479
+ }
1480
+ case 'retry': {
1481
+ if (!lastPromptText) {
1482
+ queuedAppend({ id: `retry-err-${Date.now()}`, kind: 'error', message: 'nothing to retry yet' });
1483
+ }
1484
+ else {
1485
+ queuedAppend({ id: `retry-${Date.now()}`, kind: 'text', text: `retrying: ${lastPromptText.slice(0, 120)}`, role: 'assistant' });
1486
+ await runWithBridge(lastPromptText);
1487
+ }
1488
+ return;
1489
+ }
1490
+ case 'mcp': {
1491
+ try {
1492
+ const { readFileSync, existsSync } = await import('node:fs');
1493
+ const { join } = await import('node:path');
1494
+ const sub = cmd.sub?.trim().split(/\s+/)[0] ?? 'list';
1495
+ const arg = cmd.sub?.trim().split(/\s+/).slice(1).join(' ');
1496
+ const cfgPath = join(cwd, '.mcp.json');
1497
+ if (!existsSync(cfgPath)) {
1498
+ queuedAppend({ id: `mcp-${Date.now()}`, kind: 'text', text: 'No MCP servers configured (no .mcp.json). Add servers to .mcp.json.', role: 'assistant' });
1499
+ return;
1500
+ }
1501
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
1502
+ const servers = cfg.servers ?? {};
1503
+ const names = Object.keys(servers);
1504
+ if (sub === 'list' || sub === 'status' || !sub) {
1505
+ queuedAppend({ id: `mcp2-${Date.now()}`, kind: 'text', text: names.length === 0 ? 'MCP: .mcp.json has no servers.' : `MCP servers (${names.length}, lazy-connect):\n${names.map((n) => ` ${servers[n]?.disabled ? '[disabled]' : '[enabled] '} ${n}${servers[n]?.url ? ` ${servers[n].url}` : ''}`).join('\n')}`, role: 'assistant' });
1506
+ }
1507
+ else if ((sub === 'enable' || sub === 'disable') && arg) {
1508
+ if (!servers[arg]) {
1509
+ queuedAppend({ id: `mcp-err-${Date.now()}`, kind: 'error', message: `no MCP server: ${arg}` });
1510
+ }
1511
+ else {
1512
+ servers[arg].disabled = sub === 'disable';
1513
+ const { writeFileSync } = await import('node:fs');
1514
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8');
1515
+ queuedAppend({ id: `mcp3-${Date.now()}`, kind: 'text', text: `MCP server ${arg} ${sub}d`, role: 'assistant' });
1516
+ }
1517
+ }
1518
+ else if (sub === 'reconnect' && arg) {
1519
+ queuedAppend({ id: `mcp4-${Date.now()}`, kind: 'text', text: servers[arg] ? `MCP ${arg}: reconnect queued (connections are lazy — next tool use reconnects)` : `no MCP server: ${arg}`, role: 'assistant' });
1520
+ }
1521
+ else {
1522
+ queuedAppend({ id: `mcp5-${Date.now()}`, kind: 'text', text: 'usage: /mcp [list|status|enable <n>|disable <n>|reconnect <n>]', role: 'assistant' });
1523
+ }
1524
+ }
1525
+ catch (err) {
1526
+ queuedAppend({ id: `mcp-err2-${Date.now()}`, kind: 'error', message: `mcp failed: ${err instanceof Error ? err.message : String(err)}` });
1527
+ }
1528
+ return;
1529
+ }
1530
+ case 'agents': {
1531
+ queuedAppend({ id: `agents-${Date.now()}`, kind: 'text', text: `Agents (active: ${activeAgent}):\n${AGENT_ROLES.map((a) => ` ${a === activeAgent ? '*' : ' '} ${a}`).join('\n')}\nswitch via /agent <name>, spawn via /subtask <task>`, role: 'assistant' });
1532
+ return;
1533
+ }
1534
+ case 'agent': {
1535
+ const n = cmd.name?.trim().toLowerCase();
1536
+ if (!n) {
1537
+ queuedAppend({ id: `agent-${Date.now()}`, kind: 'text', text: `active agent: ${activeAgent}\nusage: /agent <${AGENT_ROLES.join('|')}>`, role: 'assistant' });
1538
+ }
1539
+ else if (!AGENT_ROLES.includes(n)) {
1540
+ queuedAppend({ id: `agent-err-${Date.now()}`, kind: 'error', message: `unknown agent: ${n} (expected ${AGENT_ROLES.join('|')})` });
1541
+ }
1542
+ else {
1543
+ activeAgent = n;
1544
+ queuedAppend({ id: `agent2-${Date.now()}`, kind: 'text', text: `active agent: ${n} (role label for future tasks)`, role: 'assistant' });
1545
+ }
1546
+ return;
1547
+ }
1548
+ case 'subagents': {
1549
+ queuedAppend({ id: `subagents-${Date.now()}`, kind: 'text', text: `Subagents: ${AGENT_ROLES.filter((a) => a !== 'default').join(', ')}\nspawn via /subtask <task> — runs a full agent loop and reports back.`, role: 'assistant' });
1550
+ return;
1551
+ }
1552
+ case 'subtask': {
1553
+ if (!cmd.task) {
1554
+ queuedAppend({ id: `subtask-${Date.now()}`, kind: 'text', text: 'usage: /subtask <task>', role: 'assistant' });
1555
+ }
1556
+ else {
1557
+ queuedAppend({ id: `subtask-run-${Date.now()}`, kind: 'text', text: `[subagent:${activeAgent}] starting: ${cmd.task.slice(0, 120)}`, role: 'assistant' });
1558
+ await runWithBridge(`[subagent task] ${cmd.task}`);
1559
+ }
1560
+ return;
1561
+ }
1562
+ case 'background': {
1563
+ if (!cmd.task) {
1564
+ queuedAppend({ id: `bg-${Date.now()}`, kind: 'text', text: bgAgentTasks.length === 0 ? 'No agent background tasks.\nusage: /background <task>' : `Agent background tasks:\n${bgAgentTasks.map((t) => ` ${t.id} [${t.status}] ${t.task.slice(0, 80)}`).join('\n')}`, role: 'assistant' });
1565
+ }
1566
+ else {
1567
+ const id = `bg-${Date.now().toString(36)}`;
1568
+ bgAgentTasks.push({ id, task: cmd.task, status: 'running' });
1569
+ queuedAppend({ id: `bg-run-${Date.now()}`, kind: 'text', text: `[background] ${id} started: ${cmd.task.slice(0, 120)}`, role: 'assistant' });
1570
+ void runWithBridge(cmd.task).then(() => {
1571
+ const t = bgAgentTasks.find((x) => x.id === id);
1572
+ if (t && t.status === 'running')
1573
+ t.status = 'done';
1574
+ }).catch(() => {
1575
+ const t = bgAgentTasks.find((x) => x.id === id);
1576
+ if (t && t.status === 'running')
1577
+ t.status = 'failed';
1578
+ });
1579
+ }
1580
+ return;
1581
+ }
1582
+ case 'add-dir': {
1583
+ const { existsSync, statSync } = await import('node:fs');
1584
+ const { resolve } = await import('node:path');
1585
+ const p = cmd.path?.trim();
1586
+ if (!p) {
1587
+ queuedAppend({ id: `adddir-${Date.now()}`, kind: 'text', text: 'usage: /add-dir <path>', role: 'assistant' });
1588
+ }
1589
+ else {
1590
+ const abs = resolve(cwd, p);
1591
+ try {
1592
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
1593
+ queuedAppend({ id: `adddir-err-${Date.now()}`, kind: 'error', message: `not a directory: ${p}` });
1594
+ }
1595
+ else {
1596
+ const cfg = policy.config;
1597
+ if (!cfg.additionalDirs?.includes(abs))
1598
+ cfg.additionalDirs = [...(cfg.additionalDirs ?? []), abs];
1599
+ queuedAppend({ id: `adddir2-${Date.now()}`, kind: 'text', text: `added allowed directory: ${abs}`, role: 'assistant' });
1600
+ }
1601
+ }
1602
+ catch (err) {
1603
+ queuedAppend({ id: `adddir-err2-${Date.now()}`, kind: 'error', message: String(err) });
1604
+ }
1605
+ }
1606
+ return;
1607
+ }
1608
+ case 'cd': {
1609
+ const { existsSync, statSync } = await import('node:fs');
1610
+ const { resolve } = await import('node:path');
1611
+ const p = cmd.path?.trim();
1612
+ if (!p) {
1613
+ queuedAppend({ id: `cd-${Date.now()}`, kind: 'text', text: `cwd: ${cwd}\nusage: /cd <path>`, role: 'assistant' });
1614
+ }
1615
+ else {
1616
+ const abs = resolve(cwd, p);
1617
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
1618
+ queuedAppend({ id: `cd-err-${Date.now()}`, kind: 'error', message: `not a directory: ${p}` });
1619
+ }
1620
+ else {
1621
+ cwd = abs;
1622
+ queuedAppend({ id: `cd2-${Date.now()}`, kind: 'text', text: `cwd → ${abs} (header refreshes on restart)`, role: 'assistant' });
1623
+ }
1624
+ }
1625
+ return;
1626
+ }
1627
+ case 'attach': {
1628
+ if (!cmd.file) {
1629
+ queuedAppend({ id: `att-${Date.now()}`, kind: 'text', text: 'usage: /attach <file>', role: 'assistant' });
1630
+ }
1631
+ else {
1632
+ try {
1633
+ const r = await registry.execute('read_file', { path: cmd.file }, { cwd, env: process.env, nonInteractive: true });
1634
+ if (!r.ok) {
1635
+ queuedAppend({ id: `att-err-${Date.now()}`, kind: 'error', message: `attach failed: ${r.error.message ?? 'read error'}` });
1636
+ }
1637
+ else {
1638
+ const body = String(r.value.content ?? '');
1639
+ attachedFiles.set(cmd.file, body.length);
1640
+ queuedAppend({ id: `att2-${Date.now()}`, kind: 'text', text: `attached ${cmd.file} (${body.length} chars) — in context for future prompts`, role: 'assistant' });
1641
+ }
1642
+ }
1643
+ catch (err) {
1644
+ queuedAppend({ id: `att-err2-${Date.now()}`, kind: 'error', message: String(err) });
1645
+ }
1646
+ }
1647
+ return;
1648
+ }
1649
+ case 'drop': {
1650
+ if (!cmd.file) {
1651
+ attachedFiles.clear();
1652
+ queuedAppend({ id: `drop-${Date.now()}`, kind: 'text', text: 'dropped all attached files', role: 'assistant' });
1653
+ }
1654
+ else if (attachedFiles.delete(cmd.file)) {
1655
+ queuedAppend({ id: `drop2-${Date.now()}`, kind: 'text', text: `dropped ${cmd.file}`, role: 'assistant' });
1656
+ }
1657
+ else {
1658
+ queuedAppend({ id: `drop-err-${Date.now()}`, kind: 'error', message: `not attached: ${cmd.file}` });
1659
+ }
1660
+ return;
1661
+ }
1662
+ case 'files': {
1663
+ queuedAppend({ id: `files-${Date.now()}`, kind: 'text', text: attachedFiles.size === 0 ? 'No files in context (attach via /attach, @path, or /mention).' : `Files in context:\n${[...attachedFiles.entries()].map(([f, n]) => ` ${f} (${n} chars)`).join('\n')}`, role: 'assistant' });
1664
+ return;
1665
+ }
1666
+ case 'image': {
1667
+ if (!cmd.path) {
1668
+ queuedAppend({ id: `img-${Date.now()}`, kind: 'text', text: 'usage: /image <path> (or @<image> inline in a prompt)', role: 'assistant' });
1669
+ }
1670
+ else {
1671
+ attachedFiles.set(cmd.path, 0);
1672
+ queuedAppend({ id: `img2-${Date.now()}`, kind: 'text', text: `attached image ${cmd.path} — reference it in your next prompt`, role: 'assistant' });
1673
+ }
1674
+ return;
1675
+ }
1676
+ case 'paste': {
1677
+ try {
1678
+ const { execSync } = await import('node:child_process');
1679
+ const probe = process.platform === 'win32' ? 'powershell -NoProfile -Command Get-Clipboard' : process.platform === 'darwin' ? 'pbpaste' : 'xclip -o -selection clipboard';
1680
+ const text = execSync(probe, { encoding: 'utf-8' }).trim();
1681
+ if (!text) {
1682
+ queuedAppend({ id: `paste-${Date.now()}`, kind: 'text', text: 'Clipboard is empty.', role: 'assistant' });
1683
+ }
1684
+ else {
1685
+ queuedAppend({ id: `paste2-${Date.now()}`, kind: 'text', text: `pasted ${text.length} chars into context:\n${text.slice(0, 3000)}`, role: 'assistant' });
1686
+ }
1687
+ }
1688
+ catch {
1689
+ queuedAppend({ id: `paste-err-${Date.now()}`, kind: 'error', message: 'clipboard unavailable on this system' });
1690
+ }
1691
+ return;
1692
+ }
1693
+ case 'ls': {
1694
+ try {
1695
+ const r = await registry.execute('list_directory', { path: cmd.path || '.' }, { cwd, env: process.env, nonInteractive: true });
1696
+ if (!r.ok) {
1697
+ queuedAppend({ id: `ls-err-${Date.now()}`, kind: 'error', message: `ls failed: ${r.error.message ?? 'error'}` });
1698
+ }
1699
+ else {
1700
+ const v = r.value;
1701
+ const lines = (v.entries ?? []).map((e) => ` ${e.type === 'directory' ? e.name + '/' : e.name}`);
1702
+ queuedAppend({ id: `ls2-${Date.now()}`, kind: 'text', text: `${cmd.path || '.'}:\n${outCap(lines.join('\n') || '(empty)')}`, role: 'assistant' });
1703
+ }
1704
+ }
1705
+ catch (err) {
1706
+ queuedAppend({ id: `ls-err2-${Date.now()}`, kind: 'error', message: String(err) });
1707
+ }
1708
+ return;
1709
+ }
1710
+ case 'tree': {
1711
+ try {
1712
+ const { readdirSync, statSync } = await import('node:fs');
1713
+ const { join, relative } = await import('node:path');
1714
+ const root = cmd.path ? join(cwd, cmd.path) : cwd;
1715
+ const out = [];
1716
+ const skip = new Set(['node_modules', '.git', 'dist', '.klyro', '.next']);
1717
+ const walk = (dir, depth) => {
1718
+ if (depth > 3 || out.length > 100)
1719
+ return;
1720
+ let entries = [];
1721
+ try {
1722
+ entries = readdirSync(dir);
1723
+ }
1724
+ catch {
1725
+ return;
1726
+ }
1727
+ for (const e of entries) {
1728
+ if (skip.has(e))
1729
+ continue;
1730
+ const full = join(dir, e);
1731
+ let isDir = false;
1732
+ try {
1733
+ isDir = statSync(full).isDirectory();
1734
+ }
1735
+ catch {
1736
+ continue;
1737
+ }
1738
+ out.push(`${' '.repeat(depth)}${isDir ? e + '/' : e}`);
1739
+ if (isDir)
1740
+ walk(full, depth + 1);
1741
+ }
1742
+ };
1743
+ walk(root, 0);
1744
+ queuedAppend({ id: `tree-${Date.now()}`, kind: 'text', text: `${relative(cwd, root) || '.'}/\n${out.join('\n').slice(0, 4000)}`, role: 'assistant' });
1745
+ }
1746
+ catch (err) {
1747
+ queuedAppend({ id: `tree-err-${Date.now()}`, kind: 'error', message: String(err) });
1748
+ }
1749
+ return;
1750
+ }
1751
+ case 'search': {
1752
+ if (!cmd.query) {
1753
+ queuedAppend({ id: `search-${Date.now()}`, kind: 'text', text: 'usage: /search <query>', role: 'assistant' });
1754
+ }
1755
+ else {
1756
+ try {
1757
+ const r = await registry.execute('grep', { pattern: cmd.query, maxResults: 50 }, { cwd, env: process.env, nonInteractive: true });
1758
+ if (!r.ok) {
1759
+ queuedAppend({ id: `search-err-${Date.now()}`, kind: 'error', message: `search failed: ${r.error.message ?? 'error'}` });
1760
+ }
1761
+ else {
1762
+ queuedAppend({ id: `search2-${Date.now()}`, kind: 'text', text: `Results for "${cmd.query}":\n${outCap(JSON.stringify(r.value, null, 2))}`, role: 'assistant' });
1763
+ }
1764
+ }
1765
+ catch (err) {
1766
+ queuedAppend({ id: `search-err2-${Date.now()}`, kind: 'error', message: String(err) });
1767
+ }
1768
+ }
1769
+ return;
1770
+ }
1771
+ case 'web': {
1772
+ if (!cmd.url) {
1773
+ queuedAppend({ id: `web-${Date.now()}`, kind: 'text', text: 'usage: /web <url>', role: 'assistant' });
1774
+ }
1775
+ else {
1776
+ try {
1777
+ const ctrl = new AbortController();
1778
+ const t = setTimeout(() => ctrl.abort(), 15_000);
1779
+ const res = await fetch(cmd.url, { signal: ctrl.signal });
1780
+ clearTimeout(t);
1781
+ const text = (await res.text()).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 6000);
1782
+ queuedAppend({ id: `web2-${Date.now()}`, kind: 'text', text: `${cmd.url} [${res.status}]:\n${text || '(no text content)'}`, role: 'assistant' });
1783
+ }
1784
+ catch (err) {
1785
+ queuedAppend({ id: `web-err-${Date.now()}`, kind: 'error', message: `fetch failed: ${err instanceof Error ? err.message : String(err)}` });
1786
+ }
1787
+ }
1788
+ return;
1789
+ }
1790
+ case 'read': {
1791
+ if (!cmd.path) {
1792
+ queuedAppend({ id: `read-${Date.now()}`, kind: 'text', text: 'usage: /read <path>', role: 'assistant' });
1793
+ }
1794
+ else {
1795
+ try {
1796
+ const r = await registry.execute('read_file', { path: cmd.path }, { cwd, env: process.env, nonInteractive: true });
1797
+ if (!r.ok) {
1798
+ queuedAppend({ id: `read-err-${Date.now()}`, kind: 'error', message: `read failed: ${r.error.message ?? 'error'}` });
1799
+ }
1800
+ else {
1801
+ queuedAppend({ id: `read2-${Date.now()}`, kind: 'text', text: `${cmd.path}:\n${outCap(String(r.value.content ?? ''))}`, role: 'assistant' });
1802
+ }
1803
+ }
1804
+ catch (err) {
1805
+ queuedAppend({ id: `read-err2-${Date.now()}`, kind: 'error', message: String(err) });
1806
+ }
1807
+ }
1808
+ return;
1809
+ }
1810
+ case 'map': {
1811
+ try {
1812
+ const r = await registry.execute('repo_map', {}, { cwd, env: process.env, nonInteractive: true });
1813
+ if (!r.ok) {
1814
+ queuedAppend({ id: `map-err-${Date.now()}`, kind: 'error', message: `map failed: ${r.error.message ?? 'error'}` });
1815
+ }
1816
+ else {
1817
+ queuedAppend({ id: `map2-${Date.now()}`, kind: 'text', text: `Repository map:\n${outCap(typeof r.value === 'string' ? r.value : JSON.stringify(r.value, null, 2))}`, role: 'assistant' });
1818
+ }
1819
+ }
1820
+ catch (err) {
1821
+ queuedAppend({ id: `map-err2-${Date.now()}`, kind: 'error', message: String(err) });
1822
+ }
1823
+ return;
1824
+ }
1825
+ case 'tokens': {
1826
+ const { getModelInfo } = await import('../providers/model-info.js');
1827
+ const info = getModelInfo(model);
1828
+ const inp = lastStatus?.usageInput ?? 0;
1829
+ const outp = lastStatus?.usageOutput ?? 0;
1830
+ const total = inp + outp;
1831
+ const pct = ((total / info.contextWindow) * 100).toFixed(1);
1832
+ queuedAppend({ id: `tokens-${Date.now()}`, kind: 'text', text: `Tokens — ${model} (window ${info.contextWindow.toLocaleString()}):\n in ${inp.toLocaleString()} / out ${outp.toLocaleString()} / total ${total.toLocaleString()} (${pct}%)`, role: 'assistant' });
1833
+ return;
1834
+ }
1835
+ case 'commit': {
1836
+ if (!cmd.message) {
1837
+ queuedAppend({ id: `commit-${Date.now()}`, kind: 'text', text: 'usage: /commit <message> (commits staged changes only — stage with git add first)', role: 'assistant' });
1838
+ }
1839
+ else {
1840
+ try {
1841
+ const st = await execShell('git status --porcelain');
1842
+ const staged = st.stdout.split('\n').filter((l) => /^[MADRC]/.test(l));
1843
+ if (staged.length === 0) {
1844
+ queuedAppend({ id: `commit2-${Date.now()}`, kind: 'text', text: 'Nothing staged — stage changes with `git add` first.', role: 'assistant' });
1845
+ }
1846
+ else {
1847
+ const safeMsg = cmd.message.replace(/"/g, "'");
1848
+ const v = await execShell(`git commit -m "${safeMsg}"`);
1849
+ queuedAppend({ id: `commit3-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `committed ${staged.length} file(s):\n${outCap(v.stdout)}` : `commit failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
1850
+ }
1851
+ }
1852
+ catch (err) {
1853
+ queuedAppend({ id: `commit-err-${Date.now()}`, kind: 'error', message: String(err) });
1854
+ }
1855
+ }
1856
+ return;
1857
+ }
1858
+ case 'push': {
1859
+ try {
1860
+ queuedAppend({ id: `push-run-${Date.now()}`, kind: 'text', text: '[push] running `git push`...', role: 'assistant' });
1861
+ const v = await execShell('git push', 300_000);
1862
+ queuedAppend({ id: `push-res-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `[push] done:\n${outCap(v.stdout + v.stderr)}` : `[push] failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
1863
+ }
1864
+ catch (err) {
1865
+ queuedAppend({ id: `push-err-${Date.now()}`, kind: 'error', message: String(err) });
1866
+ }
1867
+ return;
1868
+ }
1869
+ case 'pull': {
1870
+ try {
1871
+ const v = await execShell('git pull --ff-only', 300_000);
1872
+ queuedAppend({ id: `pull-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `[pull] done:\n${outCap(v.stdout + v.stderr)}` : `[pull] failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
1873
+ }
1874
+ catch (err) {
1875
+ queuedAppend({ id: `pull-err-${Date.now()}`, kind: 'error', message: String(err) });
1876
+ }
1877
+ return;
1878
+ }
1879
+ case 'pr': {
1880
+ try {
1881
+ const hasGh = await execShell('gh --version').then(() => true).catch(() => false);
1882
+ if (!hasGh) {
1883
+ queuedAppend({ id: `pr-${Date.now()}`, kind: 'text', text: 'gh CLI not installed — install from https://cli.github.com then use /pr [create|status|view].', role: 'assistant' });
1884
+ }
1885
+ else {
1886
+ const v = await execShell(`gh pr ${cmd.args || 'status'}`);
1887
+ queuedAppend({ id: `pr2-${Date.now()}`, kind: 'text', text: outCap(v.stdout + v.stderr) || '(no output)', role: 'assistant' });
1888
+ }
1889
+ }
1890
+ catch (err) {
1891
+ queuedAppend({ id: `pr-err-${Date.now()}`, kind: 'error', message: String(err) });
1892
+ }
1893
+ return;
1894
+ }
1895
+ case 'issue': {
1896
+ try {
1897
+ const hasGh = await execShell('gh --version').then(() => true).catch(() => false);
1898
+ if (!hasGh) {
1899
+ queuedAppend({ id: `issue-${Date.now()}`, kind: 'text', text: 'gh CLI not installed — install from https://cli.github.com.', role: 'assistant' });
1900
+ }
1901
+ else {
1902
+ const v = await execShell(cmd.id ? `gh issue view ${cmd.id}` : 'gh issue status');
1903
+ queuedAppend({ id: `issue2-${Date.now()}`, kind: 'text', text: outCap(v.stdout + v.stderr) || '(no output)', role: 'assistant' });
1904
+ }
1905
+ }
1906
+ catch (err) {
1907
+ queuedAppend({ id: `issue-err-${Date.now()}`, kind: 'error', message: String(err) });
1908
+ }
1909
+ return;
1910
+ }
1911
+ case 'editor': {
1912
+ const file = cmd.file?.trim();
1913
+ const ed = process.env.EDITOR ?? process.env.VISUAL ?? (process.platform === 'win32' ? 'notepad' : 'vi');
1914
+ if (!file) {
1915
+ queuedAppend({ id: `ed-${Date.now()}`, kind: 'text', text: `editor: ${ed}\nusage: /editor <file>`, role: 'assistant' });
1916
+ }
1917
+ else {
1918
+ try {
1919
+ const { startBackground } = await import('../tools/shell/background.js');
1920
+ const { resolve } = await import('node:path');
1921
+ const abs = resolve(cwd, file);
1922
+ const opener = process.platform === 'win32' ? `start "" "${abs}"` : process.platform === 'darwin' ? `open "${abs}"` : `xdg-open "${abs}"`;
1923
+ startBackground(opener, cwd);
1924
+ queuedAppend({ id: `ed2-${Date.now()}`, kind: 'text', text: `opened ${abs} in background`, role: 'assistant' });
1925
+ }
1926
+ catch (err) {
1927
+ queuedAppend({ id: `ed-err-${Date.now()}`, kind: 'error', message: String(err) });
1928
+ }
1929
+ }
1930
+ return;
1931
+ }
1932
+ case 'keymap':
1933
+ case 'vim':
1934
+ case 'theme':
1935
+ case 'statusline':
1936
+ case 'output-style': {
1937
+ const key = cmd.kind === 'keymap' ? 'klyro.keymap' : cmd.kind === 'vim' ? 'klyro.vim' : cmd.kind === 'theme' ? 'klyro.theme' : cmd.kind === 'statusline' ? 'klyro.statusline' : 'klyro.outputStyle';
1938
+ const val = (cmd.kind === 'keymap' ? cmd.name : cmd.kind === 'vim' ? cmd.state : cmd.kind === 'theme' ? cmd.name : cmd.kind === 'statusline' ? cmd.format : cmd.style)?.trim();
1939
+ const { runConfig } = await import('./config.js');
1940
+ const capture = async (args) => {
1941
+ const orig = process.stdout.write.bind(process.stdout);
1942
+ let out = '';
1943
+ process.stdout.write = ((c) => { out += String(c); return true; });
1944
+ try {
1945
+ await runConfig(args);
1946
+ }
1947
+ finally {
1948
+ process.stdout.write = orig;
1949
+ }
1950
+ return out;
1951
+ };
1952
+ if (!val) {
1953
+ const out = await capture(['get', key]);
1954
+ queuedAppend({ id: `${cmd.kind}-${Date.now()}`, kind: 'text', text: out.trim() || `${cmd.kind}: (not set)\nusage: /${cmd.kind} <value>`, role: 'assistant' });
1955
+ }
1956
+ else {
1957
+ await capture(['set', key, val]);
1958
+ queuedAppend({ id: `${cmd.kind}2-${Date.now()}`, kind: 'text', text: `${cmd.kind} set to ${val}`, role: 'assistant' });
1959
+ }
1960
+ return;
1961
+ }
1962
+ case 'debug': {
1963
+ const info = [
1964
+ `klyro debug:`,
1965
+ ` node ${process.version} platform ${process.platform}/${process.arch}`,
1966
+ ` cwd ${cwd}`,
1967
+ ` provider ${currentProvider} ${currentBaseUrl}`,
1968
+ ` model ${model} effort ${effortLevel} maxSteps ${currentMaxSteps} fast ${fastMode ? 'on' : 'off'}`,
1969
+ ` mode ${displayMode} agent ${activeAgent}`,
1970
+ ` apiKey: ${currentApiKey ? 'set (' + currentApiKey.length + ' chars)' : 'empty'}`,
1971
+ ` session ${tuiSessionId?.slice(0, 8) ?? '(none)'} attached ${attachedFiles.size} aliases ${aliases.size} prompts ${savedPrompts.size}`,
1972
+ ];
1973
+ queuedAppend({ id: `debug-${Date.now()}`, kind: 'text', text: info.join('\n'), role: 'assistant' });
1974
+ return;
1975
+ }
1976
+ case 'whoami': {
1977
+ let user = process.env.USER ?? process.env.USERNAME ?? 'unknown';
1978
+ try {
1979
+ user = (await import('node:os')).userInfo().username;
1980
+ }
1981
+ catch { /* keep env */ }
1982
+ queuedAppend({ id: `who-${Date.now()}`, kind: 'text', text: `user: ${user}\nprovider: ${currentProvider} (${currentBaseUrl})\nmodel: ${model}`, role: 'assistant' });
1983
+ return;
1984
+ }
1985
+ case 'reload': {
1986
+ try {
1987
+ const ctx = await buildLevel6Context({ cwd });
1988
+ ctxPrefix = ctx.formatted ? `\n\n<context>\n${ctx.formatted}\n</context>` : '';
1989
+ const md = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
1990
+ klyroBlock = md ? `\n\n<KLYRO.md>\n${md.slice(0, 4000)}\n</KLYRO.md>` : '';
1991
+ queuedAppend({ id: `reload-${Date.now()}`, kind: 'text', text: 'reloaded project context + KLYRO.md', role: 'assistant' });
1992
+ }
1993
+ catch (err) {
1994
+ queuedAppend({ id: `reload-err-${Date.now()}`, kind: 'error', message: String(err) });
1995
+ }
1996
+ return;
1997
+ }
1998
+ case 'reset': {
1999
+ effortLevel = 'medium';
2000
+ currentMaxSteps = EFFORT_STEPS.medium;
2001
+ fastMode = false;
2002
+ displayMode = 'default';
2003
+ policy.config.mode = 'default';
2004
+ activeAgent = 'default';
2005
+ verboseMode = false;
2006
+ detailsMode = false;
2007
+ rawMode = false;
2008
+ queuedStatus({ maxSteps: currentMaxSteps });
2009
+ queuedAppend({ id: `reset-${Date.now()}`, kind: 'text', text: 'settings reset to defaults (effort medium, mode default, agent default)', role: 'assistant' });
2010
+ return;
2011
+ }
2012
+ case 'bug': {
2013
+ queuedAppend({ id: `bug-${Date.now()}`, kind: 'text', text: `Report a bug: https://github.com/Siddu-lingampelli/Klyro/issues\nInclude: klyro --version, node ${process.version}, provider ${currentProvider}, steps to reproduce.`, role: 'assistant' });
2014
+ return;
2015
+ }
2016
+ case 'changelog': {
2017
+ try {
2018
+ const v = await execShell('git log --oneline -15');
2019
+ queuedAppend({ id: `cl-${Date.now()}`, kind: 'text', text: v.exitCode === 0 && v.stdout.trim() ? `Recent changes:\n${v.stdout.slice(0, 3000)}` : 'No git history here — see npm klyro versions for releases.', role: 'assistant' });
2020
+ }
2021
+ catch (err) {
2022
+ queuedAppend({ id: `cl-err-${Date.now()}`, kind: 'error', message: String(err) });
2023
+ }
2024
+ return;
2025
+ }
2026
+ case 'promptcmd': {
2027
+ const rest = cmd.args?.trim() ?? '';
2028
+ if (!rest) {
2029
+ queuedAppend({ id: `pc-${Date.now()}`, kind: 'text', text: savedPrompts.size === 0 ? 'No saved prompts.\nusage: /prompt save <name> <text> | /prompt <name>' : `Saved prompts:\n${[...savedPrompts.keys()].map((k) => ` ${k}`).join('\n')}\nrun via /prompt <name>`, role: 'assistant' });
2030
+ }
2031
+ else if (rest.startsWith('save ')) {
2032
+ const m = /^save\s+(\S+)\s+([\s\S]+)$/.exec(rest);
2033
+ if (!m) {
2034
+ queuedAppend({ id: `pc-err-${Date.now()}`, kind: 'error', message: 'usage: /prompt save <name> <text>' });
2035
+ }
2036
+ else {
2037
+ savedPrompts.set(m[1], m[2]);
2038
+ persistMap(promptFile, savedPrompts);
2039
+ queuedAppend({ id: `pc2-${Date.now()}`, kind: 'text', text: `saved prompt "${m[1]}"`, role: 'assistant' });
2040
+ }
2041
+ }
2042
+ else {
2043
+ const name = rest.split(/\s+/)[0];
2044
+ const text = savedPrompts.get(name);
2045
+ if (!text) {
2046
+ queuedAppend({ id: `pc-err2-${Date.now()}`, kind: 'error', message: `no saved prompt: ${name}` });
2047
+ }
2048
+ else {
2049
+ await runWithBridge(text);
2050
+ }
2051
+ }
2052
+ return;
2053
+ }
2054
+ case 'alias': {
2055
+ const rest = cmd.args?.trim() ?? '';
2056
+ if (!rest) {
2057
+ queuedAppend({ id: `al-${Date.now()}`, kind: 'text', text: aliases.size === 0 ? 'No aliases.\nusage: /alias <name> <command>' : `Aliases:\n${[...aliases.entries()].map(([k, v]) => ` /${k} → ${v}`).join('\n')}`, role: 'assistant' });
2058
+ }
2059
+ else {
2060
+ const m = /^(\S+)\s+([\s\S]+)$/.exec(rest);
2061
+ if (!m) {
2062
+ queuedAppend({ id: `al-err-${Date.now()}`, kind: 'error', message: 'usage: /alias <name> <command>' });
2063
+ }
2064
+ else {
2065
+ aliases.set(m[1], m[2]);
2066
+ persistMap(aliasFile, aliases);
2067
+ queuedAppend({ id: `al2-${Date.now()}`, kind: 'text', text: `alias /${m[1]} → ${m[2]}`, role: 'assistant' });
2068
+ }
2069
+ }
2070
+ return;
2071
+ }
2072
+ case 'commands': {
2073
+ const { COMMAND_DEFS } = await import('./slash/parser.js');
2074
+ const custom = [...aliases.keys()].map((k) => ` /${k} (alias)`);
2075
+ void custom;
2076
+ queuedAppend({ id: `cmds-${Date.now()}`, kind: 'text', text: `Commands (${COMMAND_DEFS.length + aliases.size + savedPrompts.size}):\n${COMMAND_DEFS.map((d) => ` /${d.name} — ${d.hint}`).join('\n')}${aliases.size > 0 ? `\ncustom aliases:\n${[...aliases.entries()].map(([k, v]) => ` /${k} → ${v}`).join('\n')}` : ''}${savedPrompts.size > 0 ? `\nsaved prompts: ${[...savedPrompts.keys()].join(', ')}` : ''}`, role: 'assistant' });
2077
+ return;
2078
+ }
2079
+ case 'env': {
2080
+ const { existsSync } = await import('node:fs');
2081
+ void existsSync;
2082
+ const a = cmd.args?.trim() ?? '';
2083
+ if (!a) {
2084
+ const rows = Object.keys(process.env).filter((k) => k.startsWith('KLYRO_') || ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'NO_COLOR'].includes(k)).map((k) => {
2085
+ const v = process.env[k] ?? '';
2086
+ const secret = /KEY|SECRET|TOKEN/.test(k);
2087
+ return ` ${k}=${secret ? (v ? '(set, ' + v.length + ' chars)' : '(empty)') : v || '(empty)'}`;
2088
+ });
2089
+ queuedAppend({ id: `env-${Date.now()}`, kind: 'text', text: rows.length === 0 ? 'No KLYRO_* env set.\nusage: /env KEY=value (session-only)' : `Environment:\n${rows.join('\n')}\nset via /env KEY=value (session-only)`, role: 'assistant' });
2090
+ }
2091
+ else {
2092
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(a);
2093
+ if (!m) {
2094
+ queuedAppend({ id: `env-err-${Date.now()}`, kind: 'error', message: 'usage: /env KEY=value' });
2095
+ }
2096
+ else {
2097
+ process.env[m[1]] = m[2];
2098
+ queuedAppend({ id: `env2-${Date.now()}`, kind: 'text', text: `set ${m[1]} (session-only)`, role: 'assistant' });
2099
+ }
2100
+ }
2101
+ return;
2102
+ }
2103
+ case 'deps': {
2104
+ try {
2105
+ const { readFileSync } = await import('node:fs');
2106
+ const { join } = await import('node:path');
2107
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
2108
+ const d = Object.entries(pkg.dependencies ?? {}).map(([k, v]) => ` ${k}@${v}`);
2109
+ const dd = Object.entries(pkg.devDependencies ?? {}).map(([k, v]) => ` ${k}@${v} (dev)`);
2110
+ queuedAppend({ id: `deps-${Date.now()}`, kind: 'text', text: d.length + dd.length === 0 ? 'No dependencies in package.json.' : `Dependencies:\n${[...d, ...dd].join('\n').slice(0, 3000)}`, role: 'assistant' });
2111
+ }
2112
+ catch {
2113
+ queuedAppend({ id: `deps-err-${Date.now()}`, kind: 'error', message: 'no package.json in cwd' });
2114
+ }
2115
+ return;
2116
+ }
2117
+ case 'install': {
2118
+ try {
2119
+ const { existsSync } = await import('node:fs');
2120
+ const { join } = await import('node:path');
2121
+ const mgr = existsSync(join(cwd, 'pnpm-lock.yaml')) ? 'pnpm install' : existsSync(join(cwd, 'package-lock.json')) ? 'npm install' : existsSync(join(cwd, 'bun.lockb')) ? 'bun install' : existsSync(join(cwd, 'yarn.lock')) ? 'yarn install' : 'npm install';
2122
+ queuedAppend({ id: `inst-run-${Date.now()}`, kind: 'text', text: `[install] running \`${mgr}\`...`, role: 'assistant' });
2123
+ const v = await execShell(mgr, 600_000);
2124
+ queuedAppend({ id: `inst-res-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? '[install] done' : `[install] failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
2125
+ }
2126
+ catch (err) {
2127
+ queuedAppend({ id: `inst-err-${Date.now()}`, kind: 'error', message: String(err) });
2128
+ }
2129
+ return;
2130
+ }
1021
2131
  case 'prompt': {
1022
2132
  // Regular prompts never reach onSlash — no-op for exhaustiveness.
1023
2133
  return;
1024
2134
  }
1025
- case 'unknown':
2135
+ case 'unknown': {
2136
+ // Alias expansion: /alias <name> <command> redirects unknown commands
2137
+ const m = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(cmd.raw.trim());
2138
+ const target = m ? aliases.get(m[1].toLowerCase()) : undefined;
2139
+ if (m && target) {
2140
+ const extra = m[2] ? ` ${m[2]}` : '';
2141
+ await handleSlash(parse(target + extra));
2142
+ return;
2143
+ }
1026
2144
  queuedAppend({
1027
2145
  id: `unk-${Date.now()}`,
1028
2146
  kind: 'error',
1029
- message: `unknown command: ${cmd.raw} (try /help)`,
2147
+ message: `unknown command: ${cmd.raw} (try /help or /commands)`,
1030
2148
  });
1031
2149
  return;
2150
+ }
1032
2151
  }
1033
2152
  }
1034
2153
  // Keep process alive until user quits; resolve on unmount or SIGINT.
1035
2154
  // ac.aborted indicates SIGINT; return 130 (128+SIGINT) like shells do.
1036
2155
  return new Promise((resolve) => {
1037
2156
  const onExit = () => {
1038
- if (sigintHandler)
2157
+ if (sigintHandler) {
1039
2158
  process.removeListener('SIGINT', sigintHandler);
2159
+ process.removeListener('SIGTERM', sigintHandler);
2160
+ }
2161
+ restoreConsole();
1040
2162
  leaveAlt();
2163
+ // §1.2 exit behavior: replay a plain-text transcript into the main
2164
+ // buffer so the session survives in native scrollback.
2165
+ if (exitMirror.length > 0) {
2166
+ try {
2167
+ process.stdout.write('\n--- klyro session transcript ---\n');
2168
+ for (const line of exitMirror.slice(-100))
2169
+ process.stdout.write(line + '\n');
2170
+ }
2171
+ catch { /* ignore */ }
2172
+ }
1041
2173
  resolve(ac.signal.aborted ? 130 : 0);
1042
2174
  };
1043
2175
  if (!app) {