klyro 0.1.43 → 0.1.45

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,17 +61,17 @@ 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 });
70
71
  const t = _ctx.telemetry ? '\n\n' + _ctx.telemetry : '';
71
72
  return base + t;
72
73
  };
73
- const ac = new AbortController();
74
+ let ac = new AbortController();
74
75
  // When the TUI is mounted, use the inline Ink prompt. Otherwise
75
76
  // fall back to stdin readline. The bridge is shared between the
76
77
  // App and the runtime so the modal can resolve the runtime's ask().
@@ -140,6 +141,91 @@ export async function startRepl(opts = {}) {
140
141
  if (isAltScreen)
141
142
  enterAlt();
142
143
  const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
144
+ // P1 session/permission state (commands.md Priority 1)
145
+ let sessionLabel = '';
146
+ let currentBranch = '';
147
+ let fastMode = false;
148
+ let displayMode = 'default';
149
+ let lastAssistantText = '';
150
+ // P2 state (commands.md Priority 2)
151
+ let activeAgent = 'default';
152
+ let verboseMode = false;
153
+ let detailsMode = false;
154
+ let rawMode = false;
155
+ let lastPromptText = '';
156
+ const attachedFiles = new Map();
157
+ const bgAgentTasks = [];
158
+ const aliases = new Map();
159
+ const savedPrompts = new Map();
160
+ const AGENT_ROLES = ['default', 'explorer', 'implementer', 'tester', 'reviewer'];
161
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
162
+ const aliasFile = `${homeDir}/.klyro/aliases.json`.replace(/\\/g, '/');
163
+ const promptFile = `${homeDir}/.klyro/prompts.json`.replace(/\\/g, '/');
164
+ try {
165
+ const { readFileSync, existsSync } = await import('node:fs');
166
+ if (existsSync(aliasFile)) {
167
+ const raw = JSON.parse(readFileSync(aliasFile, 'utf-8'));
168
+ for (const [k, v] of Object.entries(raw))
169
+ aliases.set(k, v);
170
+ }
171
+ if (existsSync(promptFile)) {
172
+ const raw = JSON.parse(readFileSync(promptFile, 'utf-8'));
173
+ for (const [k, v] of Object.entries(raw))
174
+ savedPrompts.set(k, v);
175
+ }
176
+ }
177
+ catch { /* best-effort */ }
178
+ function persistMap(file, m) {
179
+ try {
180
+ const fs = require('node:fs');
181
+ const path = require('node:path');
182
+ fs.mkdirSync(path.dirname(file), { recursive: true });
183
+ fs.writeFileSync(file, JSON.stringify(Object.fromEntries(m), null, 2), 'utf-8');
184
+ }
185
+ catch { /* best-effort */ }
186
+ }
187
+ /** Truncation budget honoring /verbose and /raw. */
188
+ function outCap(s) {
189
+ const cap = rawMode ? 12000 : verboseMode ? 8000 : 4000;
190
+ return s.length > cap ? s.slice(0, cap) + `\n... [truncated ${s.length - cap} chars]` : s;
191
+ }
192
+ async function execShell(command, timeoutMs = 120_000) {
193
+ const r = await registry.execute('shell_exec', { command, timeoutMs }, { cwd, env: process.env, nonInteractive: true });
194
+ if (!r.ok)
195
+ throw new Error(r.error.message ?? 'shell failed');
196
+ return r.value;
197
+ }
198
+ /** Read-only LLM answer (no tools) — powers /ask and /explain. */
199
+ async function answerReadOnly(question, context) {
200
+ const sys = systemPromptFn({ cwd, telemetry: '' }) + '\n\nAnswer read-only: do not call tools, do not edit files.';
201
+ const userText = context ? `${question}\n\n<context>\n${context.slice(0, 6000)}\n</context>` : question;
202
+ const req = {
203
+ model,
204
+ system: sys,
205
+ messages: [{ role: 'user', content: [{ kind: 'text', text: userText }] }],
206
+ tools: [],
207
+ signal: ac.signal,
208
+ };
209
+ queuedStatus({ status: 'running', step: 0, model });
210
+ let text = '';
211
+ try {
212
+ for await (const ev of adapter.stream(req)) {
213
+ if (ev.kind === 'text_delta') {
214
+ text += ev.text;
215
+ queuedDelta(ev.text);
216
+ }
217
+ else if (ev.kind === 'error')
218
+ throw new Error(ev.message);
219
+ }
220
+ lastAssistantText = text;
221
+ queuedStatus({ status: 'done' });
222
+ }
223
+ catch (err) {
224
+ const msg = err instanceof Error ? err.message : String(err);
225
+ queuedAppend({ id: `ro-err-${Date.now()}`, kind: 'error', message: msg });
226
+ queuedStatus({ status: 'error', errorMessage: msg });
227
+ }
228
+ }
143
229
  function queuedClear() {
144
230
  if (isMounted && directHooks)
145
231
  directHooks.clearTranscript();
@@ -156,6 +242,7 @@ export async function startRepl(opts = {}) {
156
242
  approvalBridge: tuiBridge,
157
243
  isFullscreen: isAltScreen,
158
244
  onPrompt: async (text) => {
245
+ lastPromptText = text;
159
246
  inflight = runWithBridge(text);
160
247
  await inflight;
161
248
  inflight = null;
@@ -235,6 +322,7 @@ export async function startRepl(opts = {}) {
235
322
  else if (ev.kind === 'error')
236
323
  throw new Error(ev.message);
237
324
  }
325
+ lastAssistantText = simpleText;
238
326
  queuedStatus({ status: 'done' });
239
327
  return;
240
328
  }
@@ -352,6 +440,8 @@ export async function startRepl(opts = {}) {
352
440
  }
353
441
  },
354
442
  }, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
443
+ if (result.finalText)
444
+ lastAssistantText = result.finalText;
355
445
  if (result.verification) {
356
446
  const v = result.verification;
357
447
  queuedAppend({
@@ -407,29 +497,18 @@ export async function startRepl(opts = {}) {
407
497
  return;
408
498
  case 'help': {
409
499
  const helpText = [
410
- 'commands:',
411
- ' /clear — clear transcript',
412
- ' /compact [focus] compact context (clears transcript, keeps marker)',
413
- ' /model [id] — show or switch model mid-session',
414
- ' /provider [name] show or switch provider (openai|anthropic)',
415
- ' /effort [level] — show or set effort (low|medium|high|max steps)',
416
- ' /diff — show git diff',
417
- ' /status — show session status',
418
- ' /plan — show current plan/todos',
419
- ' /verify — detect + run verifiers',
420
- ' /project — project scan',
421
- ' /context — context breakdown',
422
- ' /cost — token cost',
423
- ' /jobs — background jobs',
424
- ' /memory — session notes',
425
- ' /undo /rewind — checkpoints',
426
- ' /login /logout — credentials',
427
- ' /init — create KLYRO.md',
428
- ' /config — show config path',
429
- ' /doctor — run diagnostics',
430
- ' /version — show version',
431
- ' /quit (/exit) — exit',
432
- `provider: ${currentProvider} model: ${model} effort: ${effortLevel} (${currentMaxSteps} steps) cwd: ${cwd}`,
500
+ 'commands (Priority 1):',
501
+ ' session: /new /clear /compact [focus] /resume [id] /sessions /rename [n] /fork [p] /branch [n] /export [f] /copy [n] /quit',
502
+ ' model: /model [id] /models /provider [name] /effort [low|medium|high|max] /fast [on|off]',
503
+ ' project: /init /status /context /diff /plan [task] /todos /memory',
504
+ ' perms: /permissions /mode [m] /sandbox [dir] /approve /deny',
505
+ ' app: /login /logout /auth /version /update /cancel /shell (!cmd) /mention (@path) /tools /config /doctor',
506
+ ' P2: /review /code-review /security-review /simplify /test /lint /build /run /fix /explain /format /ask',
507
+ ' /undo /redo /rewind /checkpoint /accept /reject /details /verbose /raw /activity /tasks /queue /retry',
508
+ ' /mcp /agents /subtask /background /attach /files /ls /tree /search /web /read /map /tokens',
509
+ ' /commit /push /pull /pr /issue /theme /debug /whoami /reload /reset /prompt /alias /commands /env /deps /install',
510
+ ' (!cmd runs shell, @path attaches a file; /commands lists everything)',
511
+ `provider: ${currentProvider} model: ${model} effort: ${effortLevel}${fastMode ? ' fast' : ''} (${currentMaxSteps} steps) mode: ${displayMode} cwd: ${cwd}${sessionLabel ? ` session: ${sessionLabel}` : ''}${currentBranch ? ` branch: ${currentBranch}` : ''}`,
433
512
  ].join('\n');
434
513
  queuedAppend({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
435
514
  return;
@@ -504,7 +583,7 @@ export async function startRepl(opts = {}) {
504
583
  });
505
584
  }
506
585
  else {
507
- queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${currentProvider} effort: ${effortLevel} (${currentMaxSteps} steps) cwd: ${cwd}`, role: 'assistant' });
586
+ 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' });
508
587
  }
509
588
  return;
510
589
  }
@@ -701,6 +780,9 @@ export async function startRepl(opts = {}) {
701
780
  return;
702
781
  }
703
782
  case 'plan': {
783
+ if (cmd.task) {
784
+ queuedAppend({ id: `plan-mode-${Date.now()}`, kind: 'text', text: `Plan mode: "${cmd.task}" — the agent will plan first (todo_write) before editing. Current plan below:`, role: 'assistant' });
785
+ }
704
786
  try {
705
787
  const { readFileSync, existsSync } = await import('node:fs');
706
788
  const { join } = await import('node:path');
@@ -718,17 +800,1292 @@ export async function startRepl(opts = {}) {
718
800
  }
719
801
  return;
720
802
  }
721
- case 'prompt': {
722
- // Regular prompts never reach onSlash — no-op for exhaustiveness.
803
+ case 'todos': {
804
+ try {
805
+ const { readFileSync, existsSync } = await import('node:fs');
806
+ const { join } = await import('node:path');
807
+ const todosPath = join(cwd, '.klyro', 'plans', 'todos.json');
808
+ if (!existsSync(todosPath)) {
809
+ queuedAppend({ id: `todos-${Date.now()}`, kind: 'text', text: 'No todos (no .klyro/plans/todos.json yet).', role: 'assistant' });
810
+ }
811
+ else {
812
+ const arr = JSON.parse(readFileSync(todosPath, 'utf-8'));
813
+ const lines = arr.map((t) => `${t.status === 'done' ? '[x]' : t.status === 'in_progress' ? '[>]' : '[ ]'} ${t.title} (${t.id})`);
814
+ queuedAppend({ id: `todos2-${Date.now()}`, kind: 'text', text: `Todos (${arr.length}):\n${lines.join('\n').slice(0, 3000)}`, role: 'assistant' });
815
+ }
816
+ }
817
+ catch (err) {
818
+ queuedAppend({ id: `todos-err-${Date.now()}`, kind: 'error', message: `todos failed: ${err instanceof Error ? err.message : String(err)}` });
819
+ }
723
820
  return;
724
821
  }
725
- case 'unknown':
726
- queuedAppend({
727
- id: `unk-${Date.now()}`,
728
- kind: 'error',
729
- message: `unknown command: ${cmd.raw} (try /help)`,
822
+ case 'new': {
823
+ queuedClear();
824
+ sessionLabel = '';
825
+ currentBranch = '';
826
+ try {
827
+ const rec = await tuiStore.create({ cwd, task: 'new session', config: { model, maxSteps: currentMaxSteps } });
828
+ tuiSessionId = rec.id;
829
+ queuedAppend({ id: `new-${Date.now()}`, kind: 'text', text: `new session ${rec.id.slice(0, 8)} started`, role: 'assistant' });
830
+ }
831
+ catch {
832
+ queuedAppend({ id: `new2-${Date.now()}`, kind: 'text', text: 'new session started', role: 'assistant' });
833
+ }
834
+ queuedStatus({ status: 'idle', step: 0 });
835
+ return;
836
+ }
837
+ case 'models': {
838
+ const { MODEL_REGISTRY } = await import('../providers/model-info.js');
839
+ const lines = Object.values(MODEL_REGISTRY).map((m) => ` ${m.id} ctx ${(m.contextWindow / 1000).toFixed(0)}k $${m.inputPricePer1k}/$${m.outputPricePer1k} per 1k`);
840
+ queuedAppend({ id: `models-${Date.now()}`, kind: 'text', text: `Available models (current: ${model}):\n${lines.join('\n')}\nusage: /model <id>`, role: 'assistant' });
841
+ return;
842
+ }
843
+ case 'fast': {
844
+ const s = cmd.state?.trim().toLowerCase();
845
+ if (!s) {
846
+ queuedAppend({ id: `fast-${Date.now()}`, kind: 'text', text: `fast mode: ${fastMode ? 'on' : 'off'} (${currentMaxSteps} steps)\nusage: /fast on|off`, role: 'assistant' });
847
+ }
848
+ else if (s === 'on') {
849
+ fastMode = true;
850
+ currentMaxSteps = 10;
851
+ queuedStatus({ maxSteps: currentMaxSteps });
852
+ queuedAppend({ id: `fast2-${Date.now()}`, kind: 'text', text: 'fast mode on (10 max steps)', role: 'assistant' });
853
+ }
854
+ else if (s === 'off') {
855
+ fastMode = false;
856
+ currentMaxSteps = EFFORT_STEPS[effortLevel];
857
+ queuedStatus({ maxSteps: currentMaxSteps });
858
+ queuedAppend({ id: `fast3-${Date.now()}`, kind: 'text', text: `fast mode off (restored ${effortLevel}: ${currentMaxSteps} steps)`, role: 'assistant' });
859
+ }
860
+ else {
861
+ queuedAppend({ id: `fast-err-${Date.now()}`, kind: 'error', message: `unknown /fast value: ${s} (expected on|off)` });
862
+ }
863
+ return;
864
+ }
865
+ case 'permissions': {
866
+ const cfg = policy.config;
867
+ const lines = [
868
+ `mode: ${displayMode} (engine: ${String(cfg.mode ?? 'default')})`,
869
+ `shellAllow: ${(cfg.shellAllow ?? []).length} prefixes`,
870
+ `shellDeny: ${(cfg.shellDeny ?? []).length} patterns`,
871
+ `allow: ${JSON.stringify(cfg.allow ?? [])}`,
872
+ `deny: ${JSON.stringify(cfg.deny ?? [])}`,
873
+ `ask: ${JSON.stringify(cfg.ask ?? [])}`,
874
+ `sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}`,
875
+ ];
876
+ queuedAppend({ id: `perm-${Date.now()}`, kind: 'text', text: `Permissions:\n${lines.join('\n')}\nchange via /mode <manual|accept-edits|plan|auto|yolo>`, role: 'assistant' });
877
+ return;
878
+ }
879
+ case 'mode': {
880
+ const m = cmd.mode?.trim().toLowerCase();
881
+ if (!m) {
882
+ queuedAppend({ id: `mode-${Date.now()}`, kind: 'text', text: `current mode: ${displayMode}\nmodes: manual | accept-edits | plan | auto | yolo\nusage: /mode <mode>`, role: 'assistant' });
883
+ }
884
+ else {
885
+ const map = { manual: 'default', 'accept-edits': 'accept-edits', plan: 'plan', auto: 'auto', yolo: 'auto' };
886
+ const engineMode = map[m];
887
+ if (!engineMode) {
888
+ queuedAppend({ id: `mode-err-${Date.now()}`, kind: 'error', message: `unknown mode: ${m} (expected manual|accept-edits|plan|auto|yolo)` });
889
+ }
890
+ else {
891
+ policy.config.mode = engineMode;
892
+ displayMode = m;
893
+ queuedAppend({ id: `mode2-${Date.now()}`, kind: 'text', text: `mode set to ${m}${m === 'yolo' ? ' (auto-approve everything — careful)' : ''}${m === 'plan' ? ' (writes blocked)' : ''}`, role: 'assistant' });
894
+ }
895
+ }
896
+ return;
897
+ }
898
+ case 'sandbox': {
899
+ const cfg = policy.config;
900
+ const p = cmd.policy?.trim();
901
+ if (!p) {
902
+ queuedAppend({ id: `sb-${Date.now()}`, kind: 'text', text: `sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}\nusage: /sandbox <dir> (adds an allowed directory)`, role: 'assistant' });
903
+ }
904
+ else {
905
+ cfg.additionalDirs = [...(cfg.additionalDirs ?? []), p];
906
+ queuedAppend({ id: `sb2-${Date.now()}`, kind: 'text', text: `sandbox: added allowed dir ${p}`, role: 'assistant' });
907
+ }
908
+ return;
909
+ }
910
+ case 'approve': {
911
+ const ok = tuiBridge.resolve('allow');
912
+ queuedAppend({ id: `appr-${Date.now()}`, kind: 'text', text: ok ? 'approved pending action' : 'no pending approval', role: 'assistant' });
913
+ return;
914
+ }
915
+ case 'deny': {
916
+ const ok = tuiBridge.resolve('deny');
917
+ queuedAppend({ id: `deny-${Date.now()}`, kind: 'text', text: ok ? 'denied pending action' : 'no pending approval', role: 'assistant' });
918
+ return;
919
+ }
920
+ case 'resume': {
921
+ try {
922
+ const { resolveSessionId } = await import('../persistence/session.js');
923
+ const all = (await tuiStore.list()).filter((r) => r.cwd === cwd).sort((a, b) => b.updatedAt - a.updatedAt);
924
+ const full = cmd.id ? await resolveSessionId(tuiStore, cmd.id) : all[0]?.id;
925
+ if (!full) {
926
+ queuedAppend({ id: `resume-err-${Date.now()}`, kind: 'error', message: cmd.id ? `session not found: ${cmd.id}` : 'no previous session in this cwd' });
927
+ return;
928
+ }
929
+ const rec = await tuiStore.get(full);
930
+ const msgs = await tuiStore.loadMessages(full);
931
+ tuiSessionId = full;
932
+ sessionLabel = rec?.task.slice(0, 40) ?? '';
933
+ queuedAppend({ id: `resume-${Date.now()}`, kind: 'text', text: `resumed session ${full.slice(0, 8)} — "${rec?.task}" (${msgs.length} messages, status ${rec?.status})`, role: 'assistant' });
934
+ queuedStatus({ status: 'idle' });
935
+ }
936
+ catch (err) {
937
+ queuedAppend({ id: `resume-err2-${Date.now()}`, kind: 'error', message: `resume failed: ${err instanceof Error ? err.message : String(err)}` });
938
+ }
939
+ return;
940
+ }
941
+ case 'sessions': {
942
+ try {
943
+ const { formatSession } = await import('../persistence/session.js');
944
+ const all = (await tuiStore.list()).sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 20);
945
+ if (all.length === 0) {
946
+ queuedAppend({ id: `sess-${Date.now()}`, kind: 'text', text: 'No sessions yet.', role: 'assistant' });
947
+ }
948
+ else {
949
+ const lines = all.map((r) => `${r.id.slice(0, 8) === tuiSessionId?.slice(0, 8) ? '*' : ' '} ${formatSession(r)}`);
950
+ queuedAppend({ id: `sess2-${Date.now()}`, kind: 'text', text: `Sessions (* = current):\n${lines.join('\n')}\nusage: /resume <id>`, role: 'assistant' });
951
+ }
952
+ }
953
+ catch (err) {
954
+ queuedAppend({ id: `sess-err-${Date.now()}`, kind: 'error', message: `sessions failed: ${err instanceof Error ? err.message : String(err)}` });
955
+ }
956
+ return;
957
+ }
958
+ case 'rename': {
959
+ if (!cmd.name) {
960
+ queuedAppend({ id: `ren-${Date.now()}`, kind: 'text', text: `current session label: ${sessionLabel || '(none)'}\nusage: /rename <name>`, role: 'assistant' });
961
+ }
962
+ else {
963
+ sessionLabel = cmd.name;
964
+ queuedAppend({ id: `ren2-${Date.now()}`, kind: 'text', text: `session renamed to "${cmd.name}"`, role: 'assistant' });
965
+ }
966
+ return;
967
+ }
968
+ case 'fork': {
969
+ try {
970
+ const base = sessionLabel || 'session';
971
+ const rec = await tuiStore.create({ cwd, task: `${base} (fork)${cmd.prompt ? `: ${cmd.prompt}` : ''}`, config: { model, maxSteps: currentMaxSteps } });
972
+ tuiSessionId = rec.id;
973
+ queuedAppend({ id: `fork-${Date.now()}`, kind: 'text', text: `forked → session ${rec.id.slice(0, 8)}`, role: 'assistant' });
974
+ }
975
+ catch (err) {
976
+ queuedAppend({ id: `fork-err-${Date.now()}`, kind: 'error', message: `fork failed: ${err instanceof Error ? err.message : String(err)}` });
977
+ }
978
+ return;
979
+ }
980
+ case 'branch': {
981
+ if (!cmd.name) {
982
+ queuedAppend({ id: `br-${Date.now()}`, kind: 'text', text: `current branch: ${currentBranch || '(none)'}\nusage: /branch <name>`, role: 'assistant' });
983
+ }
984
+ else {
985
+ currentBranch = cmd.name;
986
+ queuedAppend({ id: `br2-${Date.now()}`, kind: 'text', text: `branch "${cmd.name}" created — conversation continues here`, role: 'assistant' });
987
+ }
988
+ return;
989
+ }
990
+ case 'export': {
991
+ try {
992
+ const all = (await tuiStore.list()).filter((r) => r.cwd === cwd).sort((a, b) => b.updatedAt - a.updatedAt);
993
+ const target = tuiSessionId ?? all[0]?.id;
994
+ if (!target) {
995
+ queuedAppend({ id: `exp-err-${Date.now()}`, kind: 'error', message: 'nothing to export (no session)' });
996
+ return;
997
+ }
998
+ const rec = await tuiStore.get(target);
999
+ const msgs = await tuiStore.loadMessages(target);
1000
+ const out = cmd.file ?? `${target.slice(0, 8)}.export.json`;
1001
+ await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs }, null, 2), 'utf-8');
1002
+ queuedAppend({ id: `exp-${Date.now()}`, kind: 'text', text: `exported ${target.slice(0, 8)} → ${out} (${msgs.length} messages)`, role: 'assistant' });
1003
+ }
1004
+ catch (err) {
1005
+ queuedAppend({ id: `exp-err2-${Date.now()}`, kind: 'error', message: `export failed: ${err instanceof Error ? err.message : String(err)}` });
1006
+ }
1007
+ return;
1008
+ }
1009
+ case 'copy': {
1010
+ if (!lastAssistantText) {
1011
+ queuedAppend({ id: `copy-err-${Date.now()}`, kind: 'error', message: 'nothing to copy yet (no assistant response this session)' });
1012
+ return;
1013
+ }
1014
+ const n = cmd.n ? parseInt(cmd.n, 10) : NaN;
1015
+ const text = Number.isFinite(n) && n > 0 ? lastAssistantText.slice(0, n) : lastAssistantText;
1016
+ try {
1017
+ const { execSync } = await import('node:child_process');
1018
+ const clip = process.platform === 'win32' ? 'clip' : process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
1019
+ execSync(clip, { input: text });
1020
+ queuedAppend({ id: `copy-${Date.now()}`, kind: 'text', text: `copied ${text.length} chars to clipboard`, role: 'assistant' });
1021
+ }
1022
+ catch {
1023
+ queuedAppend({ id: `copy2-${Date.now()}`, kind: 'text', text: `clipboard unavailable — response preview (${text.length} chars):\n${text.slice(0, 500)}`, role: 'assistant' });
1024
+ }
1025
+ return;
1026
+ }
1027
+ case 'auth': {
1028
+ const { getStoredKey } = await import('./auth.js');
1029
+ const rows = ['openai', 'anthropic'].map((p) => {
1030
+ const hasFile = !!getStoredKey(p);
1031
+ const hasEnv = !!(p === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY) || !!process.env.KLYRO_API_KEY;
1032
+ return ` ${p}: ${hasFile ? 'stored key (0600)' : hasEnv ? 'env key' : '—'}`;
730
1033
  });
1034
+ queuedAppend({ id: `auth-${Date.now()}`, kind: 'text', text: `Auth:\n${rows.join('\n')}\ncurrent provider: ${currentProvider}\nmanage via /login /logout`, role: 'assistant' });
1035
+ return;
1036
+ }
1037
+ case 'update': {
1038
+ const { runUpdate } = await import('./update.js');
1039
+ const origWrite = process.stdout.write.bind(process.stdout);
1040
+ let out = '';
1041
+ process.stdout.write = ((chunk) => { out += String(chunk); return true; });
1042
+ await runUpdate();
1043
+ process.stdout.write = origWrite;
1044
+ queuedAppend({ id: `upd-${Date.now()}`, kind: 'text', text: out || 'update check done', role: 'assistant' });
1045
+ return;
1046
+ }
1047
+ case 'cancel': {
1048
+ ac.abort();
1049
+ ac = new AbortController();
1050
+ queuedStatus({ status: 'aborted' });
1051
+ queuedAppend({ id: `cancel-${Date.now()}`, kind: 'text', text: 'cancelled current operation', role: 'assistant' });
731
1052
  return;
1053
+ }
1054
+ case 'shell': {
1055
+ if (!cmd.command) {
1056
+ queuedAppend({ id: `sh-${Date.now()}`, kind: 'text', text: 'usage: /shell <command> (or !<command>)', role: 'assistant' });
1057
+ return;
1058
+ }
1059
+ try {
1060
+ const r = await registry.execute('shell_exec', { command: cmd.command }, { cwd, env: process.env, nonInteractive: true });
1061
+ if (!r.ok) {
1062
+ queuedAppend({ id: `sh-err-${Date.now()}`, kind: 'error', message: `shell failed: ${r.error.message ?? r.error.code}` });
1063
+ }
1064
+ else {
1065
+ const v = r.value;
1066
+ const body = (v.stdout + (v.stderr ? `\n[stderr]\n${v.stderr}` : '')).slice(0, 4000) || '(no output)';
1067
+ queuedAppend({ id: `sh2-${Date.now()}`, kind: 'text', text: `$ ${cmd.command}\nexit ${v.exitCode}\n${body}`, role: 'assistant' });
1068
+ }
1069
+ }
1070
+ catch (err) {
1071
+ queuedAppend({ id: `sh-err2-${Date.now()}`, kind: 'error', message: `shell failed: ${err instanceof Error ? err.message : String(err)}` });
1072
+ }
1073
+ return;
1074
+ }
1075
+ case 'mention': {
1076
+ if (!cmd.path) {
1077
+ queuedAppend({ id: `men-${Date.now()}`, kind: 'text', text: 'usage: /mention <path> (or @<path>)', role: 'assistant' });
1078
+ return;
1079
+ }
1080
+ try {
1081
+ const r = await registry.execute('read_file', { path: cmd.path }, { cwd, env: process.env, nonInteractive: true });
1082
+ if (!r.ok) {
1083
+ queuedAppend({ id: `men-err-${Date.now()}`, kind: 'error', message: `mention failed: ${r.error.message ?? r.error.code}` });
1084
+ }
1085
+ else {
1086
+ const v = r.value;
1087
+ const body = String(v.content ?? v.text ?? JSON.stringify(v)).slice(0, 6000);
1088
+ queuedAppend({ id: `men2-${Date.now()}`, kind: 'text', text: `attached ${cmd.path} (${body.length} chars):\n${body}`, role: 'assistant' });
1089
+ }
1090
+ }
1091
+ catch (err) {
1092
+ queuedAppend({ id: `men-err2-${Date.now()}`, kind: 'error', message: `mention failed: ${err instanceof Error ? err.message : String(err)}` });
1093
+ }
1094
+ return;
1095
+ }
1096
+ case 'tools': {
1097
+ const lines = registry.list().map((t) => ` ${t.name} — ${t.description.slice(0, 80)}`);
1098
+ queuedAppend({ id: `tools-${Date.now()}`, kind: 'text', text: `Tools (${lines.length}):\n${lines.join('\n')}`, role: 'assistant' });
1099
+ return;
1100
+ }
1101
+ case 'settings': {
1102
+ const { getConfigPath } = await import('./config.js');
1103
+ queuedAppend({ id: `set-${Date.now()}`, kind: 'text', text: `config: ${getConfigPath()} (alias of /config)`, role: 'assistant' });
1104
+ return;
1105
+ }
1106
+ case 'review': {
1107
+ try {
1108
+ const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
1109
+ if (!r.ok) {
1110
+ queuedAppend({ id: `rev-err-${Date.now()}`, kind: 'error', message: `review failed: ${r.error.message ?? 'git_diff error'}` });
1111
+ }
1112
+ else {
1113
+ const v = r.value;
1114
+ const head = v.patchedFiles.length === 0 ? 'Working tree clean — nothing to review.' : `Reviewing ${v.patchedFiles.length} file(s): ${v.patchedFiles.join(', ')}`;
1115
+ let extra = '';
1116
+ if (cmd.target) {
1117
+ try {
1118
+ const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
1119
+ if (fr.ok)
1120
+ extra = `\n\n--- ${cmd.target} ---\n${String(fr.value.content ?? '').slice(0, 2000)}`;
1121
+ }
1122
+ catch { /* ignore */ }
1123
+ }
1124
+ 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' });
1125
+ }
1126
+ }
1127
+ catch (err) {
1128
+ queuedAppend({ id: `rev-err2-${Date.now()}`, kind: 'error', message: `review failed: ${err instanceof Error ? err.message : String(err)}` });
1129
+ }
1130
+ return;
1131
+ }
1132
+ case 'code-review': {
1133
+ try {
1134
+ const { checkImports } = await import('../verification/scoped.js');
1135
+ const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
1136
+ if (!r.ok) {
1137
+ queuedAppend({ id: `cr-err-${Date.now()}`, kind: 'error', message: 'code-review failed: no diff available' });
1138
+ }
1139
+ else {
1140
+ const v = r.value;
1141
+ const findings = [];
1142
+ for (const f of v.patchedFiles.slice(0, 10)) {
1143
+ const ic = checkImports(cwd, f);
1144
+ for (const m of ic.missing)
1145
+ findings.push(`missing import '${m}' in ${f}`);
1146
+ if (f.length > 200)
1147
+ findings.push(`suspicious path length: ${f}`);
1148
+ }
1149
+ if (/(^|\/)\.env(\.|$)/.test(v.diff))
1150
+ findings.push('.env content in diff — never commit secrets');
1151
+ if (/console\.log|debugger/.test(v.diff))
1152
+ findings.push('debug leftovers (console.log/debugger) in diff');
1153
+ if (/\.skip\(|\.todo\(|xit\(|xtest\(/.test(v.diff))
1154
+ findings.push('skipped tests in diff');
1155
+ const verdict = findings.length === 0 ? 'No issues found.' : `Findings (${findings.length}):\n- ${findings.join('\n- ')}`;
1156
+ 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' });
1157
+ }
1158
+ }
1159
+ catch (err) {
1160
+ queuedAppend({ id: `cr-err2-${Date.now()}`, kind: 'error', message: `code-review failed: ${err instanceof Error ? err.message : String(err)}` });
1161
+ }
1162
+ return;
1163
+ }
1164
+ case 'security-review': {
1165
+ try {
1166
+ const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
1167
+ const diff = r.ok ? r.value.diff : '';
1168
+ const checks = [
1169
+ [/sk-[A-Za-z0-9]{8,}|sk-ant-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{10,}/, 'possible hardcoded secret in diff'],
1170
+ [/(^|\/)\.env(\.|$)/, '.env content in diff'],
1171
+ [/\beval\s*\(/, 'eval() usage in diff'],
1172
+ [/rm\s+-rf\s+(\/|~|\*)/, 'dangerous rm -rf in diff'],
1173
+ [/chmod\s+-R\s+777/, 'chmod 777 in diff'],
1174
+ [/curl.*\|\s*(sh|bash)/i, 'curl|sh pipe in diff'],
1175
+ [/password\s*=\s*["'][^"']+["']/i, 'hardcoded password in diff'],
1176
+ ];
1177
+ const hits = checks.filter(([re]) => re.test(diff)).map(([, msg]) => msg);
1178
+ 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' });
1179
+ }
1180
+ catch (err) {
1181
+ queuedAppend({ id: `sr-err-${Date.now()}`, kind: 'error', message: `security-review failed: ${err instanceof Error ? err.message : String(err)}` });
1182
+ }
1183
+ return;
1184
+ }
1185
+ case 'simplify': {
1186
+ await runWithBridge(`Simplify ${cmd.target ?? 'recent changes'}: refactor for clarity without changing behavior. Keep the diff minimal.`);
1187
+ return;
1188
+ }
1189
+ case 'test': {
1190
+ try {
1191
+ const { primaryVerifyCommand } = await import('../verification/registry.js');
1192
+ const { buildScopedCommand } = await import('../verification/scoped.js');
1193
+ const { verify } = await import('../verification/engine.js');
1194
+ const base = primaryVerifyCommand(cwd);
1195
+ if (!base) {
1196
+ queuedAppend({ id: `tst-${Date.now()}`, kind: 'text', text: 'No test command detected. Try /verify or run tests manually.', role: 'assistant' });
1197
+ }
1198
+ else {
1199
+ const scoped = cmd.target ? buildScopedCommand(cwd, base, [cmd.target]) ?? base : base;
1200
+ queuedAppend({ id: `tst-run-${Date.now()}`, kind: 'text', text: `[test] running \`${scoped}\`...`, role: 'assistant' });
1201
+ const res = await verify({ cwd, command: scoped, timeoutMs: 120_000 });
1202
+ queuedAppend({ id: `tst-res-${Date.now()}`, kind: 'text', text: res.ok ? `[test] passed (${scoped})` : `[test] failed:\n${outCap(res.stderr || res.stdout)}`, role: 'assistant' });
1203
+ }
1204
+ }
1205
+ catch (err) {
1206
+ queuedAppend({ id: `tst-err-${Date.now()}`, kind: 'error', message: `test failed: ${err instanceof Error ? err.message : String(err)}` });
1207
+ }
1208
+ return;
1209
+ }
1210
+ case 'lint': {
1211
+ try {
1212
+ const { readFileSync, existsSync } = await import('node:fs');
1213
+ const { join } = await import('node:path');
1214
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
1215
+ let lintCmd = null;
1216
+ if (pkg.scripts?.lint)
1217
+ lintCmd = 'npm run lint --silent';
1218
+ else if (existsSync(join(cwd, 'eslint.config.js')) || existsSync(join(cwd, '.eslintrc.json')))
1219
+ lintCmd = 'npx eslint .';
1220
+ else
1221
+ lintCmd = 'npx tsc --noEmit';
1222
+ queuedAppend({ id: `lint-run-${Date.now()}`, kind: 'text', text: `[lint] running \`${lintCmd}\`...`, role: 'assistant' });
1223
+ const v = await execShell(lintCmd);
1224
+ 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' });
1225
+ }
1226
+ catch (err) {
1227
+ queuedAppend({ id: `lint-err-${Date.now()}`, kind: 'error', message: `lint failed: ${err instanceof Error ? err.message : String(err)}` });
1228
+ }
1229
+ return;
1230
+ }
1231
+ case 'build': {
1232
+ try {
1233
+ const { readFileSync } = await import('node:fs');
1234
+ const { join } = await import('node:path');
1235
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
1236
+ if (!pkg.scripts?.build) {
1237
+ queuedAppend({ id: `bld-${Date.now()}`, kind: 'text', text: 'No build script in package.json.', role: 'assistant' });
1238
+ }
1239
+ else {
1240
+ queuedAppend({ id: `bld-run-${Date.now()}`, kind: 'text', text: '[build] running `npm run build`...', role: 'assistant' });
1241
+ const v = await execShell('npm run build', 300_000);
1242
+ 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' });
1243
+ }
1244
+ }
1245
+ catch (err) {
1246
+ queuedAppend({ id: `bld-err-${Date.now()}`, kind: 'error', message: `build failed: ${err instanceof Error ? err.message : String(err)}` });
1247
+ }
1248
+ return;
1249
+ }
1250
+ case 'run': {
1251
+ if (!cmd.command) {
1252
+ queuedAppend({ id: `run-${Date.now()}`, kind: 'text', text: 'usage: /run <command>', role: 'assistant' });
1253
+ }
1254
+ else {
1255
+ try {
1256
+ const v = await execShell(cmd.command, 300_000);
1257
+ 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' });
1258
+ }
1259
+ catch (err) {
1260
+ queuedAppend({ id: `run-err-${Date.now()}`, kind: 'error', message: `run failed: ${err instanceof Error ? err.message : String(err)}` });
1261
+ }
1262
+ }
1263
+ return;
1264
+ }
1265
+ case 'fix': {
1266
+ 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.`);
1267
+ return;
1268
+ }
1269
+ case 'explain': {
1270
+ if (!cmd.target) {
1271
+ queuedAppend({ id: `exp-${Date.now()}`, kind: 'text', text: 'usage: /explain <file|symbol>', role: 'assistant' });
1272
+ }
1273
+ else {
1274
+ let ctx = '';
1275
+ try {
1276
+ const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
1277
+ if (fr.ok)
1278
+ ctx = `File ${cmd.target}:\n${String(fr.value.content ?? '').slice(0, 6000)}`;
1279
+ }
1280
+ catch { /* symbol — answer without file context */ }
1281
+ await answerReadOnly(`Explain ${cmd.target}: what it does, key logic, and gotchas.`, ctx || undefined);
1282
+ }
1283
+ return;
1284
+ }
1285
+ case 'format': {
1286
+ try {
1287
+ const v = await execShell('git status --porcelain');
1288
+ 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);
1289
+ if (files.length === 0) {
1290
+ queuedAppend({ id: `fmt-${Date.now()}`, kind: 'text', text: 'Nothing to format (no changed source files).', role: 'assistant' });
1291
+ }
1292
+ else {
1293
+ const check = await execShell('npx --no-install prettier --version').catch(() => null);
1294
+ if (!check || check.exitCode !== 0) {
1295
+ queuedAppend({ id: `fmt2-${Date.now()}`, kind: 'text', text: 'prettier not installed — run `npm i -D prettier` first.', role: 'assistant' });
1296
+ }
1297
+ else {
1298
+ const fv = await execShell(`npx prettier --write ${files.map((f) => `"${f}"`).join(' ')}`);
1299
+ 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' });
1300
+ }
1301
+ }
1302
+ }
1303
+ catch (err) {
1304
+ queuedAppend({ id: `fmt-err-${Date.now()}`, kind: 'error', message: `format failed: ${err instanceof Error ? err.message : String(err)}` });
1305
+ }
1306
+ return;
1307
+ }
1308
+ case 'ask': {
1309
+ if (!cmd.question) {
1310
+ queuedAppend({ id: `ask-${Date.now()}`, kind: 'text', text: 'usage: /ask <question> (read-only, no edits)', role: 'assistant' });
1311
+ }
1312
+ else {
1313
+ await answerReadOnly(cmd.question);
1314
+ }
1315
+ return;
1316
+ }
1317
+ case 'redo': {
1318
+ queuedAppend({ id: `redo-${Date.now()}`, kind: 'text', text: 'No redo stack — checkpoints support /undo and /rewind only.', role: 'assistant' });
1319
+ return;
1320
+ }
1321
+ case 'checkpoint': {
1322
+ try {
1323
+ const { snapshot } = await import('../checkpoints/store.js');
1324
+ const v = await execShell('git status --porcelain');
1325
+ const files = v.stdout.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
1326
+ if (files.length === 0) {
1327
+ queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: 'Working tree clean — nothing to checkpoint.', role: 'assistant' });
1328
+ }
1329
+ else {
1330
+ const id = await snapshot(cwd, files.slice(0, 50));
1331
+ queuedAppend({ id: `ckpt2-${Date.now()}`, kind: 'text', text: `checkpoint ${String(id).slice(0, 8)} — ${files.length} file(s)`, role: 'assistant' });
1332
+ }
1333
+ }
1334
+ catch (err) {
1335
+ queuedAppend({ id: `ckpt-err-${Date.now()}`, kind: 'error', message: `checkpoint failed: ${err instanceof Error ? err.message : String(err)}` });
1336
+ }
1337
+ return;
1338
+ }
1339
+ case 'accept': {
1340
+ const ok = tuiBridge.resolve('allow');
1341
+ queuedAppend({ id: `acc-${Date.now()}`, kind: 'text', text: ok ? 'accepted pending edits' : 'no pending edits to accept', role: 'assistant' });
1342
+ return;
1343
+ }
1344
+ case 'reject': {
1345
+ const ok = tuiBridge.resolve('deny');
1346
+ queuedAppend({ id: `rej-${Date.now()}`, kind: 'text', text: ok ? 'rejected pending edits' : 'no pending edits to reject', role: 'assistant' });
1347
+ return;
1348
+ }
1349
+ case 'details': {
1350
+ detailsMode = !detailsMode;
1351
+ queuedAppend({ id: `det-${Date.now()}`, kind: 'text', text: `detailed activity: ${detailsMode ? 'on (tool groups expanded by default — use ctrl+o)' : 'off'}`, role: 'assistant' });
1352
+ return;
1353
+ }
1354
+ case 'verbose': {
1355
+ verboseMode = !verboseMode;
1356
+ queuedAppend({ id: `verb-${Date.now()}`, kind: 'text', text: `verbose output: ${verboseMode ? 'on (8k output cap)' : 'off (4k output cap)'}`, role: 'assistant' });
1357
+ return;
1358
+ }
1359
+ case 'raw': {
1360
+ rawMode = !rawMode;
1361
+ queuedAppend({ id: `raw-${Date.now()}`, kind: 'text', text: `raw output: ${rawMode ? 'on (12k cap, no truncation notes)' : 'off'}`, role: 'assistant' });
1362
+ return;
1363
+ }
1364
+ case 'activity': {
1365
+ const s = lastStatus;
1366
+ const line = s ? `status=${s.status} step=${s.step}/${s.maxSteps} model=${s.model} tokens=${s.usageInput + s.usageOutput}` : `status=idle model=${model}`;
1367
+ queuedAppend({ id: `act-${Date.now()}`, kind: 'text', text: `Activity: ${line}${inflight ? ' (task running)' : ''}${tuiSessionId ? ` session=${tuiSessionId.slice(0, 8)}` : ''}`, role: 'assistant' });
1368
+ return;
1369
+ }
1370
+ case 'tasks':
1371
+ case 'ps': {
1372
+ const { listJobs } = await import('../tools/shell/background.js');
1373
+ const jobs = listJobs();
1374
+ if (jobs.length === 0)
1375
+ queuedAppend({ id: `tasks-${Date.now()}`, kind: 'text', text: 'No background jobs', role: 'assistant' });
1376
+ else
1377
+ 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' });
1378
+ if (bgAgentTasks.length > 0) {
1379
+ 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' });
1380
+ }
1381
+ return;
1382
+ }
1383
+ case 'stop':
1384
+ case 'kill': {
1385
+ const id = cmd.id?.trim();
1386
+ const { listJobs, killJob } = await import('../tools/shell/background.js');
1387
+ if (!id) {
1388
+ const jobs = listJobs();
1389
+ 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' });
1390
+ }
1391
+ else {
1392
+ const match = listJobs().find((j) => j.id.startsWith(id)) ?? listJobs().find((j) => j.id.includes(id));
1393
+ const ag = bgAgentTasks.find((t) => t.id.startsWith(id));
1394
+ if (match) {
1395
+ try {
1396
+ killJob(match.id);
1397
+ queuedAppend({ id: `stop2-${Date.now()}`, kind: 'text', text: `stopped ${match.id.slice(0, 12)}`, role: 'assistant' });
1398
+ }
1399
+ catch (err) {
1400
+ queuedAppend({ id: `stop-err-${Date.now()}`, kind: 'error', message: String(err) });
1401
+ }
1402
+ }
1403
+ else if (ag) {
1404
+ ag.status = 'stopped';
1405
+ queuedAppend({ id: `stop3-${Date.now()}`, kind: 'text', text: `marked agent task ${ag.id} stopped (in-flight loop finishes current step)`, role: 'assistant' });
1406
+ }
1407
+ else {
1408
+ queuedAppend({ id: `stop-err2-${Date.now()}`, kind: 'error', message: `no job: ${id}` });
1409
+ }
1410
+ }
1411
+ return;
1412
+ }
1413
+ case 'queue': {
1414
+ const pending = bgAgentTasks.filter((t) => t.status === 'running');
1415
+ 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' });
1416
+ return;
1417
+ }
1418
+ case 'retry': {
1419
+ if (!lastPromptText) {
1420
+ queuedAppend({ id: `retry-err-${Date.now()}`, kind: 'error', message: 'nothing to retry yet' });
1421
+ }
1422
+ else {
1423
+ queuedAppend({ id: `retry-${Date.now()}`, kind: 'text', text: `retrying: ${lastPromptText.slice(0, 120)}`, role: 'assistant' });
1424
+ await runWithBridge(lastPromptText);
1425
+ }
1426
+ return;
1427
+ }
1428
+ case 'mcp': {
1429
+ try {
1430
+ const { readFileSync, existsSync } = await import('node:fs');
1431
+ const { join } = await import('node:path');
1432
+ const sub = cmd.sub?.trim().split(/\s+/)[0] ?? 'list';
1433
+ const arg = cmd.sub?.trim().split(/\s+/).slice(1).join(' ');
1434
+ const cfgPath = join(cwd, '.mcp.json');
1435
+ if (!existsSync(cfgPath)) {
1436
+ queuedAppend({ id: `mcp-${Date.now()}`, kind: 'text', text: 'No MCP servers configured (no .mcp.json). Add servers to .mcp.json.', role: 'assistant' });
1437
+ return;
1438
+ }
1439
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
1440
+ const servers = cfg.servers ?? {};
1441
+ const names = Object.keys(servers);
1442
+ if (sub === 'list' || sub === 'status' || !sub) {
1443
+ 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' });
1444
+ }
1445
+ else if ((sub === 'enable' || sub === 'disable') && arg) {
1446
+ if (!servers[arg]) {
1447
+ queuedAppend({ id: `mcp-err-${Date.now()}`, kind: 'error', message: `no MCP server: ${arg}` });
1448
+ }
1449
+ else {
1450
+ servers[arg].disabled = sub === 'disable';
1451
+ const { writeFileSync } = await import('node:fs');
1452
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8');
1453
+ queuedAppend({ id: `mcp3-${Date.now()}`, kind: 'text', text: `MCP server ${arg} ${sub}d`, role: 'assistant' });
1454
+ }
1455
+ }
1456
+ else if (sub === 'reconnect' && arg) {
1457
+ 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' });
1458
+ }
1459
+ else {
1460
+ queuedAppend({ id: `mcp5-${Date.now()}`, kind: 'text', text: 'usage: /mcp [list|status|enable <n>|disable <n>|reconnect <n>]', role: 'assistant' });
1461
+ }
1462
+ }
1463
+ catch (err) {
1464
+ queuedAppend({ id: `mcp-err2-${Date.now()}`, kind: 'error', message: `mcp failed: ${err instanceof Error ? err.message : String(err)}` });
1465
+ }
1466
+ return;
1467
+ }
1468
+ case 'agents': {
1469
+ 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' });
1470
+ return;
1471
+ }
1472
+ case 'agent': {
1473
+ const n = cmd.name?.trim().toLowerCase();
1474
+ if (!n) {
1475
+ queuedAppend({ id: `agent-${Date.now()}`, kind: 'text', text: `active agent: ${activeAgent}\nusage: /agent <${AGENT_ROLES.join('|')}>`, role: 'assistant' });
1476
+ }
1477
+ else if (!AGENT_ROLES.includes(n)) {
1478
+ queuedAppend({ id: `agent-err-${Date.now()}`, kind: 'error', message: `unknown agent: ${n} (expected ${AGENT_ROLES.join('|')})` });
1479
+ }
1480
+ else {
1481
+ activeAgent = n;
1482
+ queuedAppend({ id: `agent2-${Date.now()}`, kind: 'text', text: `active agent: ${n} (role label for future tasks)`, role: 'assistant' });
1483
+ }
1484
+ return;
1485
+ }
1486
+ case 'subagents': {
1487
+ 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' });
1488
+ return;
1489
+ }
1490
+ case 'subtask': {
1491
+ if (!cmd.task) {
1492
+ queuedAppend({ id: `subtask-${Date.now()}`, kind: 'text', text: 'usage: /subtask <task>', role: 'assistant' });
1493
+ }
1494
+ else {
1495
+ queuedAppend({ id: `subtask-run-${Date.now()}`, kind: 'text', text: `[subagent:${activeAgent}] starting: ${cmd.task.slice(0, 120)}`, role: 'assistant' });
1496
+ await runWithBridge(`[subagent task] ${cmd.task}`);
1497
+ }
1498
+ return;
1499
+ }
1500
+ case 'background': {
1501
+ if (!cmd.task) {
1502
+ 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' });
1503
+ }
1504
+ else {
1505
+ const id = `bg-${Date.now().toString(36)}`;
1506
+ bgAgentTasks.push({ id, task: cmd.task, status: 'running' });
1507
+ queuedAppend({ id: `bg-run-${Date.now()}`, kind: 'text', text: `[background] ${id} started: ${cmd.task.slice(0, 120)}`, role: 'assistant' });
1508
+ void runWithBridge(cmd.task).then(() => {
1509
+ const t = bgAgentTasks.find((x) => x.id === id);
1510
+ if (t && t.status === 'running')
1511
+ t.status = 'done';
1512
+ }).catch(() => {
1513
+ const t = bgAgentTasks.find((x) => x.id === id);
1514
+ if (t && t.status === 'running')
1515
+ t.status = 'failed';
1516
+ });
1517
+ }
1518
+ return;
1519
+ }
1520
+ case 'add-dir': {
1521
+ const { existsSync, statSync } = await import('node:fs');
1522
+ const { resolve } = await import('node:path');
1523
+ const p = cmd.path?.trim();
1524
+ if (!p) {
1525
+ queuedAppend({ id: `adddir-${Date.now()}`, kind: 'text', text: 'usage: /add-dir <path>', role: 'assistant' });
1526
+ }
1527
+ else {
1528
+ const abs = resolve(cwd, p);
1529
+ try {
1530
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
1531
+ queuedAppend({ id: `adddir-err-${Date.now()}`, kind: 'error', message: `not a directory: ${p}` });
1532
+ }
1533
+ else {
1534
+ const cfg = policy.config;
1535
+ if (!cfg.additionalDirs?.includes(abs))
1536
+ cfg.additionalDirs = [...(cfg.additionalDirs ?? []), abs];
1537
+ queuedAppend({ id: `adddir2-${Date.now()}`, kind: 'text', text: `added allowed directory: ${abs}`, role: 'assistant' });
1538
+ }
1539
+ }
1540
+ catch (err) {
1541
+ queuedAppend({ id: `adddir-err2-${Date.now()}`, kind: 'error', message: String(err) });
1542
+ }
1543
+ }
1544
+ return;
1545
+ }
1546
+ case 'cd': {
1547
+ const { existsSync, statSync } = await import('node:fs');
1548
+ const { resolve } = await import('node:path');
1549
+ const p = cmd.path?.trim();
1550
+ if (!p) {
1551
+ queuedAppend({ id: `cd-${Date.now()}`, kind: 'text', text: `cwd: ${cwd}\nusage: /cd <path>`, role: 'assistant' });
1552
+ }
1553
+ else {
1554
+ const abs = resolve(cwd, p);
1555
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
1556
+ queuedAppend({ id: `cd-err-${Date.now()}`, kind: 'error', message: `not a directory: ${p}` });
1557
+ }
1558
+ else {
1559
+ cwd = abs;
1560
+ queuedAppend({ id: `cd2-${Date.now()}`, kind: 'text', text: `cwd → ${abs} (header refreshes on restart)`, role: 'assistant' });
1561
+ }
1562
+ }
1563
+ return;
1564
+ }
1565
+ case 'attach': {
1566
+ if (!cmd.file) {
1567
+ queuedAppend({ id: `att-${Date.now()}`, kind: 'text', text: 'usage: /attach <file>', role: 'assistant' });
1568
+ }
1569
+ else {
1570
+ try {
1571
+ const r = await registry.execute('read_file', { path: cmd.file }, { cwd, env: process.env, nonInteractive: true });
1572
+ if (!r.ok) {
1573
+ queuedAppend({ id: `att-err-${Date.now()}`, kind: 'error', message: `attach failed: ${r.error.message ?? 'read error'}` });
1574
+ }
1575
+ else {
1576
+ const body = String(r.value.content ?? '');
1577
+ attachedFiles.set(cmd.file, body.length);
1578
+ queuedAppend({ id: `att2-${Date.now()}`, kind: 'text', text: `attached ${cmd.file} (${body.length} chars) — in context for future prompts`, role: 'assistant' });
1579
+ }
1580
+ }
1581
+ catch (err) {
1582
+ queuedAppend({ id: `att-err2-${Date.now()}`, kind: 'error', message: String(err) });
1583
+ }
1584
+ }
1585
+ return;
1586
+ }
1587
+ case 'drop': {
1588
+ if (!cmd.file) {
1589
+ attachedFiles.clear();
1590
+ queuedAppend({ id: `drop-${Date.now()}`, kind: 'text', text: 'dropped all attached files', role: 'assistant' });
1591
+ }
1592
+ else if (attachedFiles.delete(cmd.file)) {
1593
+ queuedAppend({ id: `drop2-${Date.now()}`, kind: 'text', text: `dropped ${cmd.file}`, role: 'assistant' });
1594
+ }
1595
+ else {
1596
+ queuedAppend({ id: `drop-err-${Date.now()}`, kind: 'error', message: `not attached: ${cmd.file}` });
1597
+ }
1598
+ return;
1599
+ }
1600
+ case 'files': {
1601
+ 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' });
1602
+ return;
1603
+ }
1604
+ case 'image': {
1605
+ if (!cmd.path) {
1606
+ queuedAppend({ id: `img-${Date.now()}`, kind: 'text', text: 'usage: /image <path> (or @<image> inline in a prompt)', role: 'assistant' });
1607
+ }
1608
+ else {
1609
+ attachedFiles.set(cmd.path, 0);
1610
+ queuedAppend({ id: `img2-${Date.now()}`, kind: 'text', text: `attached image ${cmd.path} — reference it in your next prompt`, role: 'assistant' });
1611
+ }
1612
+ return;
1613
+ }
1614
+ case 'paste': {
1615
+ try {
1616
+ const { execSync } = await import('node:child_process');
1617
+ const probe = process.platform === 'win32' ? 'powershell -NoProfile -Command Get-Clipboard' : process.platform === 'darwin' ? 'pbpaste' : 'xclip -o -selection clipboard';
1618
+ const text = execSync(probe, { encoding: 'utf-8' }).trim();
1619
+ if (!text) {
1620
+ queuedAppend({ id: `paste-${Date.now()}`, kind: 'text', text: 'Clipboard is empty.', role: 'assistant' });
1621
+ }
1622
+ else {
1623
+ queuedAppend({ id: `paste2-${Date.now()}`, kind: 'text', text: `pasted ${text.length} chars into context:\n${text.slice(0, 3000)}`, role: 'assistant' });
1624
+ }
1625
+ }
1626
+ catch {
1627
+ queuedAppend({ id: `paste-err-${Date.now()}`, kind: 'error', message: 'clipboard unavailable on this system' });
1628
+ }
1629
+ return;
1630
+ }
1631
+ case 'ls': {
1632
+ try {
1633
+ const r = await registry.execute('list_directory', { path: cmd.path || '.' }, { cwd, env: process.env, nonInteractive: true });
1634
+ if (!r.ok) {
1635
+ queuedAppend({ id: `ls-err-${Date.now()}`, kind: 'error', message: `ls failed: ${r.error.message ?? 'error'}` });
1636
+ }
1637
+ else {
1638
+ const v = r.value;
1639
+ const lines = (v.entries ?? []).map((e) => ` ${e.type === 'directory' ? e.name + '/' : e.name}`);
1640
+ queuedAppend({ id: `ls2-${Date.now()}`, kind: 'text', text: `${cmd.path || '.'}:\n${outCap(lines.join('\n') || '(empty)')}`, role: 'assistant' });
1641
+ }
1642
+ }
1643
+ catch (err) {
1644
+ queuedAppend({ id: `ls-err2-${Date.now()}`, kind: 'error', message: String(err) });
1645
+ }
1646
+ return;
1647
+ }
1648
+ case 'tree': {
1649
+ try {
1650
+ const { readdirSync, statSync } = await import('node:fs');
1651
+ const { join, relative } = await import('node:path');
1652
+ const root = cmd.path ? join(cwd, cmd.path) : cwd;
1653
+ const out = [];
1654
+ const skip = new Set(['node_modules', '.git', 'dist', '.klyro', '.next']);
1655
+ const walk = (dir, depth) => {
1656
+ if (depth > 3 || out.length > 100)
1657
+ return;
1658
+ let entries = [];
1659
+ try {
1660
+ entries = readdirSync(dir);
1661
+ }
1662
+ catch {
1663
+ return;
1664
+ }
1665
+ for (const e of entries) {
1666
+ if (skip.has(e))
1667
+ continue;
1668
+ const full = join(dir, e);
1669
+ let isDir = false;
1670
+ try {
1671
+ isDir = statSync(full).isDirectory();
1672
+ }
1673
+ catch {
1674
+ continue;
1675
+ }
1676
+ out.push(`${' '.repeat(depth)}${isDir ? e + '/' : e}`);
1677
+ if (isDir)
1678
+ walk(full, depth + 1);
1679
+ }
1680
+ };
1681
+ walk(root, 0);
1682
+ queuedAppend({ id: `tree-${Date.now()}`, kind: 'text', text: `${relative(cwd, root) || '.'}/\n${out.join('\n').slice(0, 4000)}`, role: 'assistant' });
1683
+ }
1684
+ catch (err) {
1685
+ queuedAppend({ id: `tree-err-${Date.now()}`, kind: 'error', message: String(err) });
1686
+ }
1687
+ return;
1688
+ }
1689
+ case 'search': {
1690
+ if (!cmd.query) {
1691
+ queuedAppend({ id: `search-${Date.now()}`, kind: 'text', text: 'usage: /search <query>', role: 'assistant' });
1692
+ }
1693
+ else {
1694
+ try {
1695
+ const r = await registry.execute('grep', { pattern: cmd.query, maxResults: 50 }, { cwd, env: process.env, nonInteractive: true });
1696
+ if (!r.ok) {
1697
+ queuedAppend({ id: `search-err-${Date.now()}`, kind: 'error', message: `search failed: ${r.error.message ?? 'error'}` });
1698
+ }
1699
+ else {
1700
+ queuedAppend({ id: `search2-${Date.now()}`, kind: 'text', text: `Results for "${cmd.query}":\n${outCap(JSON.stringify(r.value, null, 2))}`, role: 'assistant' });
1701
+ }
1702
+ }
1703
+ catch (err) {
1704
+ queuedAppend({ id: `search-err2-${Date.now()}`, kind: 'error', message: String(err) });
1705
+ }
1706
+ }
1707
+ return;
1708
+ }
1709
+ case 'web': {
1710
+ if (!cmd.url) {
1711
+ queuedAppend({ id: `web-${Date.now()}`, kind: 'text', text: 'usage: /web <url>', role: 'assistant' });
1712
+ }
1713
+ else {
1714
+ try {
1715
+ const ctrl = new AbortController();
1716
+ const t = setTimeout(() => ctrl.abort(), 15_000);
1717
+ const res = await fetch(cmd.url, { signal: ctrl.signal });
1718
+ clearTimeout(t);
1719
+ const text = (await res.text()).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 6000);
1720
+ queuedAppend({ id: `web2-${Date.now()}`, kind: 'text', text: `${cmd.url} [${res.status}]:\n${text || '(no text content)'}`, role: 'assistant' });
1721
+ }
1722
+ catch (err) {
1723
+ queuedAppend({ id: `web-err-${Date.now()}`, kind: 'error', message: `fetch failed: ${err instanceof Error ? err.message : String(err)}` });
1724
+ }
1725
+ }
1726
+ return;
1727
+ }
1728
+ case 'read': {
1729
+ if (!cmd.path) {
1730
+ queuedAppend({ id: `read-${Date.now()}`, kind: 'text', text: 'usage: /read <path>', role: 'assistant' });
1731
+ }
1732
+ else {
1733
+ try {
1734
+ const r = await registry.execute('read_file', { path: cmd.path }, { cwd, env: process.env, nonInteractive: true });
1735
+ if (!r.ok) {
1736
+ queuedAppend({ id: `read-err-${Date.now()}`, kind: 'error', message: `read failed: ${r.error.message ?? 'error'}` });
1737
+ }
1738
+ else {
1739
+ queuedAppend({ id: `read2-${Date.now()}`, kind: 'text', text: `${cmd.path}:\n${outCap(String(r.value.content ?? ''))}`, role: 'assistant' });
1740
+ }
1741
+ }
1742
+ catch (err) {
1743
+ queuedAppend({ id: `read-err2-${Date.now()}`, kind: 'error', message: String(err) });
1744
+ }
1745
+ }
1746
+ return;
1747
+ }
1748
+ case 'map': {
1749
+ try {
1750
+ const r = await registry.execute('repo_map', {}, { cwd, env: process.env, nonInteractive: true });
1751
+ if (!r.ok) {
1752
+ queuedAppend({ id: `map-err-${Date.now()}`, kind: 'error', message: `map failed: ${r.error.message ?? 'error'}` });
1753
+ }
1754
+ else {
1755
+ 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' });
1756
+ }
1757
+ }
1758
+ catch (err) {
1759
+ queuedAppend({ id: `map-err2-${Date.now()}`, kind: 'error', message: String(err) });
1760
+ }
1761
+ return;
1762
+ }
1763
+ case 'tokens': {
1764
+ const { getModelInfo } = await import('../providers/model-info.js');
1765
+ const info = getModelInfo(model);
1766
+ const inp = lastStatus?.usageInput ?? 0;
1767
+ const outp = lastStatus?.usageOutput ?? 0;
1768
+ const total = inp + outp;
1769
+ const pct = ((total / info.contextWindow) * 100).toFixed(1);
1770
+ 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' });
1771
+ return;
1772
+ }
1773
+ case 'commit': {
1774
+ if (!cmd.message) {
1775
+ queuedAppend({ id: `commit-${Date.now()}`, kind: 'text', text: 'usage: /commit <message> (commits staged changes only — stage with git add first)', role: 'assistant' });
1776
+ }
1777
+ else {
1778
+ try {
1779
+ const st = await execShell('git status --porcelain');
1780
+ const staged = st.stdout.split('\n').filter((l) => /^[MADRC]/.test(l));
1781
+ if (staged.length === 0) {
1782
+ queuedAppend({ id: `commit2-${Date.now()}`, kind: 'text', text: 'Nothing staged — stage changes with `git add` first.', role: 'assistant' });
1783
+ }
1784
+ else {
1785
+ const safeMsg = cmd.message.replace(/"/g, "'");
1786
+ const v = await execShell(`git commit -m "${safeMsg}"`);
1787
+ 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' });
1788
+ }
1789
+ }
1790
+ catch (err) {
1791
+ queuedAppend({ id: `commit-err-${Date.now()}`, kind: 'error', message: String(err) });
1792
+ }
1793
+ }
1794
+ return;
1795
+ }
1796
+ case 'push': {
1797
+ try {
1798
+ queuedAppend({ id: `push-run-${Date.now()}`, kind: 'text', text: '[push] running `git push`...', role: 'assistant' });
1799
+ const v = await execShell('git push', 300_000);
1800
+ 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' });
1801
+ }
1802
+ catch (err) {
1803
+ queuedAppend({ id: `push-err-${Date.now()}`, kind: 'error', message: String(err) });
1804
+ }
1805
+ return;
1806
+ }
1807
+ case 'pull': {
1808
+ try {
1809
+ const v = await execShell('git pull --ff-only', 300_000);
1810
+ 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' });
1811
+ }
1812
+ catch (err) {
1813
+ queuedAppend({ id: `pull-err-${Date.now()}`, kind: 'error', message: String(err) });
1814
+ }
1815
+ return;
1816
+ }
1817
+ case 'pr': {
1818
+ try {
1819
+ const hasGh = await execShell('gh --version').then(() => true).catch(() => false);
1820
+ if (!hasGh) {
1821
+ 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' });
1822
+ }
1823
+ else {
1824
+ const v = await execShell(`gh pr ${cmd.args || 'status'}`);
1825
+ queuedAppend({ id: `pr2-${Date.now()}`, kind: 'text', text: outCap(v.stdout + v.stderr) || '(no output)', role: 'assistant' });
1826
+ }
1827
+ }
1828
+ catch (err) {
1829
+ queuedAppend({ id: `pr-err-${Date.now()}`, kind: 'error', message: String(err) });
1830
+ }
1831
+ return;
1832
+ }
1833
+ case 'issue': {
1834
+ try {
1835
+ const hasGh = await execShell('gh --version').then(() => true).catch(() => false);
1836
+ if (!hasGh) {
1837
+ queuedAppend({ id: `issue-${Date.now()}`, kind: 'text', text: 'gh CLI not installed — install from https://cli.github.com.', role: 'assistant' });
1838
+ }
1839
+ else {
1840
+ const v = await execShell(cmd.id ? `gh issue view ${cmd.id}` : 'gh issue status');
1841
+ queuedAppend({ id: `issue2-${Date.now()}`, kind: 'text', text: outCap(v.stdout + v.stderr) || '(no output)', role: 'assistant' });
1842
+ }
1843
+ }
1844
+ catch (err) {
1845
+ queuedAppend({ id: `issue-err-${Date.now()}`, kind: 'error', message: String(err) });
1846
+ }
1847
+ return;
1848
+ }
1849
+ case 'editor': {
1850
+ const file = cmd.file?.trim();
1851
+ const ed = process.env.EDITOR ?? process.env.VISUAL ?? (process.platform === 'win32' ? 'notepad' : 'vi');
1852
+ if (!file) {
1853
+ queuedAppend({ id: `ed-${Date.now()}`, kind: 'text', text: `editor: ${ed}\nusage: /editor <file>`, role: 'assistant' });
1854
+ }
1855
+ else {
1856
+ try {
1857
+ const { startBackground } = await import('../tools/shell/background.js');
1858
+ const { resolve } = await import('node:path');
1859
+ const abs = resolve(cwd, file);
1860
+ const opener = process.platform === 'win32' ? `start "" "${abs}"` : process.platform === 'darwin' ? `open "${abs}"` : `xdg-open "${abs}"`;
1861
+ startBackground(opener, cwd);
1862
+ queuedAppend({ id: `ed2-${Date.now()}`, kind: 'text', text: `opened ${abs} in background`, role: 'assistant' });
1863
+ }
1864
+ catch (err) {
1865
+ queuedAppend({ id: `ed-err-${Date.now()}`, kind: 'error', message: String(err) });
1866
+ }
1867
+ }
1868
+ return;
1869
+ }
1870
+ case 'keymap':
1871
+ case 'vim':
1872
+ case 'theme':
1873
+ case 'statusline':
1874
+ case 'output-style': {
1875
+ const key = cmd.kind === 'keymap' ? 'klyro.keymap' : cmd.kind === 'vim' ? 'klyro.vim' : cmd.kind === 'theme' ? 'klyro.theme' : cmd.kind === 'statusline' ? 'klyro.statusline' : 'klyro.outputStyle';
1876
+ 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();
1877
+ const { runConfig } = await import('./config.js');
1878
+ const capture = async (args) => {
1879
+ const orig = process.stdout.write.bind(process.stdout);
1880
+ let out = '';
1881
+ process.stdout.write = ((c) => { out += String(c); return true; });
1882
+ try {
1883
+ await runConfig(args);
1884
+ }
1885
+ finally {
1886
+ process.stdout.write = orig;
1887
+ }
1888
+ return out;
1889
+ };
1890
+ if (!val) {
1891
+ const out = await capture(['get', key]);
1892
+ queuedAppend({ id: `${cmd.kind}-${Date.now()}`, kind: 'text', text: out.trim() || `${cmd.kind}: (not set)\nusage: /${cmd.kind} <value>`, role: 'assistant' });
1893
+ }
1894
+ else {
1895
+ await capture(['set', key, val]);
1896
+ queuedAppend({ id: `${cmd.kind}2-${Date.now()}`, kind: 'text', text: `${cmd.kind} set to ${val}`, role: 'assistant' });
1897
+ }
1898
+ return;
1899
+ }
1900
+ case 'debug': {
1901
+ const info = [
1902
+ `klyro debug:`,
1903
+ ` node ${process.version} platform ${process.platform}/${process.arch}`,
1904
+ ` cwd ${cwd}`,
1905
+ ` provider ${currentProvider} ${currentBaseUrl}`,
1906
+ ` model ${model} effort ${effortLevel} maxSteps ${currentMaxSteps} fast ${fastMode ? 'on' : 'off'}`,
1907
+ ` mode ${displayMode} agent ${activeAgent}`,
1908
+ ` apiKey: ${currentApiKey ? 'set (' + currentApiKey.length + ' chars)' : 'empty'}`,
1909
+ ` session ${tuiSessionId?.slice(0, 8) ?? '(none)'} attached ${attachedFiles.size} aliases ${aliases.size} prompts ${savedPrompts.size}`,
1910
+ ];
1911
+ queuedAppend({ id: `debug-${Date.now()}`, kind: 'text', text: info.join('\n'), role: 'assistant' });
1912
+ return;
1913
+ }
1914
+ case 'whoami': {
1915
+ let user = process.env.USER ?? process.env.USERNAME ?? 'unknown';
1916
+ try {
1917
+ user = (await import('node:os')).userInfo().username;
1918
+ }
1919
+ catch { /* keep env */ }
1920
+ queuedAppend({ id: `who-${Date.now()}`, kind: 'text', text: `user: ${user}\nprovider: ${currentProvider} (${currentBaseUrl})\nmodel: ${model}`, role: 'assistant' });
1921
+ return;
1922
+ }
1923
+ case 'reload': {
1924
+ try {
1925
+ const ctx = await buildLevel6Context({ cwd });
1926
+ ctxPrefix = ctx.formatted ? `\n\n<context>\n${ctx.formatted}\n</context>` : '';
1927
+ const md = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
1928
+ klyroBlock = md ? `\n\n<KLYRO.md>\n${md.slice(0, 4000)}\n</KLYRO.md>` : '';
1929
+ queuedAppend({ id: `reload-${Date.now()}`, kind: 'text', text: 'reloaded project context + KLYRO.md', role: 'assistant' });
1930
+ }
1931
+ catch (err) {
1932
+ queuedAppend({ id: `reload-err-${Date.now()}`, kind: 'error', message: String(err) });
1933
+ }
1934
+ return;
1935
+ }
1936
+ case 'reset': {
1937
+ effortLevel = 'medium';
1938
+ currentMaxSteps = EFFORT_STEPS.medium;
1939
+ fastMode = false;
1940
+ displayMode = 'default';
1941
+ policy.config.mode = 'default';
1942
+ activeAgent = 'default';
1943
+ verboseMode = false;
1944
+ detailsMode = false;
1945
+ rawMode = false;
1946
+ queuedStatus({ maxSteps: currentMaxSteps });
1947
+ queuedAppend({ id: `reset-${Date.now()}`, kind: 'text', text: 'settings reset to defaults (effort medium, mode default, agent default)', role: 'assistant' });
1948
+ return;
1949
+ }
1950
+ case 'bug': {
1951
+ 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' });
1952
+ return;
1953
+ }
1954
+ case 'changelog': {
1955
+ try {
1956
+ const v = await execShell('git log --oneline -15');
1957
+ 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' });
1958
+ }
1959
+ catch (err) {
1960
+ queuedAppend({ id: `cl-err-${Date.now()}`, kind: 'error', message: String(err) });
1961
+ }
1962
+ return;
1963
+ }
1964
+ case 'promptcmd': {
1965
+ const rest = cmd.args?.trim() ?? '';
1966
+ if (!rest) {
1967
+ 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' });
1968
+ }
1969
+ else if (rest.startsWith('save ')) {
1970
+ const m = /^save\s+(\S+)\s+([\s\S]+)$/.exec(rest);
1971
+ if (!m) {
1972
+ queuedAppend({ id: `pc-err-${Date.now()}`, kind: 'error', message: 'usage: /prompt save <name> <text>' });
1973
+ }
1974
+ else {
1975
+ savedPrompts.set(m[1], m[2]);
1976
+ persistMap(promptFile, savedPrompts);
1977
+ queuedAppend({ id: `pc2-${Date.now()}`, kind: 'text', text: `saved prompt "${m[1]}"`, role: 'assistant' });
1978
+ }
1979
+ }
1980
+ else {
1981
+ const name = rest.split(/\s+/)[0];
1982
+ const text = savedPrompts.get(name);
1983
+ if (!text) {
1984
+ queuedAppend({ id: `pc-err2-${Date.now()}`, kind: 'error', message: `no saved prompt: ${name}` });
1985
+ }
1986
+ else {
1987
+ await runWithBridge(text);
1988
+ }
1989
+ }
1990
+ return;
1991
+ }
1992
+ case 'alias': {
1993
+ const rest = cmd.args?.trim() ?? '';
1994
+ if (!rest) {
1995
+ 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' });
1996
+ }
1997
+ else {
1998
+ const m = /^(\S+)\s+([\s\S]+)$/.exec(rest);
1999
+ if (!m) {
2000
+ queuedAppend({ id: `al-err-${Date.now()}`, kind: 'error', message: 'usage: /alias <name> <command>' });
2001
+ }
2002
+ else {
2003
+ aliases.set(m[1], m[2]);
2004
+ persistMap(aliasFile, aliases);
2005
+ queuedAppend({ id: `al2-${Date.now()}`, kind: 'text', text: `alias /${m[1]} → ${m[2]}`, role: 'assistant' });
2006
+ }
2007
+ }
2008
+ return;
2009
+ }
2010
+ case 'commands': {
2011
+ const { COMMAND_DEFS } = await import('./slash/parser.js');
2012
+ const custom = [...aliases.keys()].map((k) => ` /${k} (alias)`);
2013
+ void custom;
2014
+ 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' });
2015
+ return;
2016
+ }
2017
+ case 'env': {
2018
+ const { existsSync } = await import('node:fs');
2019
+ void existsSync;
2020
+ const a = cmd.args?.trim() ?? '';
2021
+ if (!a) {
2022
+ const rows = Object.keys(process.env).filter((k) => k.startsWith('KLYRO_') || ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'NO_COLOR'].includes(k)).map((k) => {
2023
+ const v = process.env[k] ?? '';
2024
+ const secret = /KEY|SECRET|TOKEN/.test(k);
2025
+ return ` ${k}=${secret ? (v ? '(set, ' + v.length + ' chars)' : '(empty)') : v || '(empty)'}`;
2026
+ });
2027
+ 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' });
2028
+ }
2029
+ else {
2030
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(a);
2031
+ if (!m) {
2032
+ queuedAppend({ id: `env-err-${Date.now()}`, kind: 'error', message: 'usage: /env KEY=value' });
2033
+ }
2034
+ else {
2035
+ process.env[m[1]] = m[2];
2036
+ queuedAppend({ id: `env2-${Date.now()}`, kind: 'text', text: `set ${m[1]} (session-only)`, role: 'assistant' });
2037
+ }
2038
+ }
2039
+ return;
2040
+ }
2041
+ case 'deps': {
2042
+ try {
2043
+ const { readFileSync } = await import('node:fs');
2044
+ const { join } = await import('node:path');
2045
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
2046
+ const d = Object.entries(pkg.dependencies ?? {}).map(([k, v]) => ` ${k}@${v}`);
2047
+ const dd = Object.entries(pkg.devDependencies ?? {}).map(([k, v]) => ` ${k}@${v} (dev)`);
2048
+ 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' });
2049
+ }
2050
+ catch {
2051
+ queuedAppend({ id: `deps-err-${Date.now()}`, kind: 'error', message: 'no package.json in cwd' });
2052
+ }
2053
+ return;
2054
+ }
2055
+ case 'install': {
2056
+ try {
2057
+ const { existsSync } = await import('node:fs');
2058
+ const { join } = await import('node:path');
2059
+ 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';
2060
+ queuedAppend({ id: `inst-run-${Date.now()}`, kind: 'text', text: `[install] running \`${mgr}\`...`, role: 'assistant' });
2061
+ const v = await execShell(mgr, 600_000);
2062
+ queuedAppend({ id: `inst-res-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? '[install] done' : `[install] failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
2063
+ }
2064
+ catch (err) {
2065
+ queuedAppend({ id: `inst-err-${Date.now()}`, kind: 'error', message: String(err) });
2066
+ }
2067
+ return;
2068
+ }
2069
+ case 'prompt': {
2070
+ // Regular prompts never reach onSlash — no-op for exhaustiveness.
2071
+ return;
2072
+ }
2073
+ case 'unknown': {
2074
+ // Alias expansion: /alias <name> <command> redirects unknown commands
2075
+ const m = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(cmd.raw.trim());
2076
+ const target = m ? aliases.get(m[1].toLowerCase()) : undefined;
2077
+ if (m && target) {
2078
+ const extra = m[2] ? ` ${m[2]}` : '';
2079
+ await handleSlash(parse(target + extra));
2080
+ return;
2081
+ }
2082
+ queuedAppend({
2083
+ id: `unk-${Date.now()}`,
2084
+ kind: 'error',
2085
+ message: `unknown command: ${cmd.raw} (try /help or /commands)`,
2086
+ });
2087
+ return;
2088
+ }
732
2089
  }
733
2090
  }
734
2091
  // Keep process alive until user quits; resolve on unmount or SIGINT.