@pentoshi/clai 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +18 -15
  2. package/dist/agent/context-manager.d.ts +27 -0
  3. package/dist/agent/context-manager.js +75 -0
  4. package/dist/agent/context-manager.js.map +1 -0
  5. package/dist/agent/runner.d.ts +21 -1
  6. package/dist/agent/runner.js +185 -74
  7. package/dist/agent/runner.js.map +1 -1
  8. package/dist/commands/doctor.js +21 -2
  9. package/dist/commands/doctor.js.map +1 -1
  10. package/dist/commands/update.js +11 -2
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/index.js +172 -5
  13. package/dist/index.js.map +1 -1
  14. package/dist/llm/anthropic.js +29 -38
  15. package/dist/llm/anthropic.js.map +1 -1
  16. package/dist/llm/gemini.d.ts +2 -0
  17. package/dist/llm/gemini.js +40 -43
  18. package/dist/llm/gemini.js.map +1 -1
  19. package/dist/llm/http.d.ts +23 -1
  20. package/dist/llm/http.js +197 -13
  21. package/dist/llm/http.js.map +1 -1
  22. package/dist/llm/nvidia.js +1 -0
  23. package/dist/llm/nvidia.js.map +1 -1
  24. package/dist/llm/ollama.js +18 -27
  25. package/dist/llm/ollama.js.map +1 -1
  26. package/dist/llm/router.d.ts +7 -0
  27. package/dist/llm/router.js +16 -23
  28. package/dist/llm/router.js.map +1 -1
  29. package/dist/modes/agent.d.ts +4 -2
  30. package/dist/modes/agent.js +2 -2
  31. package/dist/modes/agent.js.map +1 -1
  32. package/dist/modes/ask.js +3 -4
  33. package/dist/modes/ask.js.map +1 -1
  34. package/dist/os/pkgmgr.d.ts +7 -1
  35. package/dist/os/pkgmgr.js +97 -18
  36. package/dist/os/pkgmgr.js.map +1 -1
  37. package/dist/prompts/index.d.ts +7 -0
  38. package/dist/prompts/index.js +12 -4
  39. package/dist/prompts/index.js.map +1 -1
  40. package/dist/repl.d.ts +1 -0
  41. package/dist/repl.js +363 -45
  42. package/dist/repl.js.map +1 -1
  43. package/dist/safety/classifier.d.ts +7 -1
  44. package/dist/safety/classifier.js +260 -86
  45. package/dist/safety/classifier.js.map +1 -1
  46. package/dist/safety/patterns.d.ts +48 -1
  47. package/dist/safety/patterns.js +140 -7
  48. package/dist/safety/patterns.js.map +1 -1
  49. package/dist/store/config.d.ts +23 -3
  50. package/dist/store/config.js +31 -11
  51. package/dist/store/config.js.map +1 -1
  52. package/dist/store/history.d.ts +9 -0
  53. package/dist/store/history.js +58 -1
  54. package/dist/store/history.js.map +1 -1
  55. package/dist/store/keys.d.ts +2 -1
  56. package/dist/store/keys.js +7 -3
  57. package/dist/store/keys.js.map +1 -1
  58. package/dist/store/logs.d.ts +7 -0
  59. package/dist/store/logs.js +39 -1
  60. package/dist/store/logs.js.map +1 -1
  61. package/dist/store/project.d.ts +1 -0
  62. package/dist/store/project.js +34 -9
  63. package/dist/store/project.js.map +1 -1
  64. package/dist/store/scope.d.ts +32 -0
  65. package/dist/store/scope.js +161 -0
  66. package/dist/store/scope.js.map +1 -0
  67. package/dist/tools/fs.d.ts +6 -2
  68. package/dist/tools/fs.js +99 -87
  69. package/dist/tools/fs.js.map +1 -1
  70. package/dist/tools/http.d.ts +5 -3
  71. package/dist/tools/http.js +170 -31
  72. package/dist/tools/http.js.map +1 -1
  73. package/dist/tools/policies/output-policy.d.ts +13 -0
  74. package/dist/tools/policies/output-policy.js +56 -0
  75. package/dist/tools/policies/output-policy.js.map +1 -0
  76. package/dist/tools/reducers/ffuf.d.ts +6 -0
  77. package/dist/tools/reducers/ffuf.js +74 -0
  78. package/dist/tools/reducers/ffuf.js.map +1 -0
  79. package/dist/tools/reducers/generic.d.ts +2 -0
  80. package/dist/tools/reducers/generic.js +60 -0
  81. package/dist/tools/reducers/generic.js.map +1 -0
  82. package/dist/tools/reducers/gobuster.d.ts +2 -0
  83. package/dist/tools/reducers/gobuster.js +36 -0
  84. package/dist/tools/reducers/gobuster.js.map +1 -0
  85. package/dist/tools/reducers/httpx.d.ts +2 -0
  86. package/dist/tools/reducers/httpx.js +38 -0
  87. package/dist/tools/reducers/httpx.js.map +1 -0
  88. package/dist/tools/reducers/nmap.d.ts +7 -0
  89. package/dist/tools/reducers/nmap.js +82 -0
  90. package/dist/tools/reducers/nmap.js.map +1 -0
  91. package/dist/tools/reducers/nuclei.d.ts +2 -0
  92. package/dist/tools/reducers/nuclei.js +51 -0
  93. package/dist/tools/reducers/nuclei.js.map +1 -0
  94. package/dist/tools/reducers/sqlmap.d.ts +2 -0
  95. package/dist/tools/reducers/sqlmap.js +39 -0
  96. package/dist/tools/reducers/sqlmap.js.map +1 -0
  97. package/dist/tools/reducers/subdomains.d.ts +6 -0
  98. package/dist/tools/reducers/subdomains.js +31 -0
  99. package/dist/tools/reducers/subdomains.js.map +1 -0
  100. package/dist/tools/reducers/types.d.ts +14 -0
  101. package/dist/tools/reducers/types.js +2 -0
  102. package/dist/tools/reducers/types.js.map +1 -0
  103. package/dist/tools/registry.d.ts +1 -1
  104. package/dist/tools/registry.js +223 -79
  105. package/dist/tools/registry.js.map +1 -1
  106. package/dist/tools/shell.d.ts +45 -4
  107. package/dist/tools/shell.js +419 -88
  108. package/dist/tools/shell.js.map +1 -1
  109. package/dist/tools/validate.d.ts +37 -0
  110. package/dist/tools/validate.js +144 -0
  111. package/dist/tools/validate.js.map +1 -0
  112. package/dist/types.d.ts +7 -15
  113. package/dist/ui/keys.d.ts +21 -0
  114. package/dist/ui/keys.js +13 -0
  115. package/dist/ui/keys.js.map +1 -0
  116. package/dist/ui/output-pane.d.ts +31 -0
  117. package/dist/ui/output-pane.js +81 -0
  118. package/dist/ui/output-pane.js.map +1 -0
  119. package/package.json +1 -1
package/dist/repl.js CHANGED
@@ -3,18 +3,20 @@ import { stdin as input, stdout as output } from "node:process";
3
3
  import chalk from "chalk";
4
4
  import { search } from "@inquirer/prompts";
5
5
  import { runAskStream } from "./modes/ask.js";
6
- import { runAgent } from "./modes/agent.js";
6
+ import { runAgent, createSessionPolicy, } from "./modes/agent.js";
7
7
  import { providerSwitcher, printProviderKeys, setProviderKey, unsetProviderKey, } from "./commands/providers.js";
8
8
  import { getConfig, getProviderModel, setDefaultMode, setProviderModel, setThinking, updateConfig, } from "./store/config.js";
9
9
  import { listSessions, saveSession } from "./store/history.js";
10
10
  import { assertProvider, defaultModels } from "./llm/provider.js";
11
- import { runUpdate, checkForUpdateSilent, getCurrentVersion } from "./commands/update.js";
11
+ import { runUpdate, checkForUpdateSilent, getCurrentVersion, } from "./commands/update.js";
12
12
  import { renderBanner, renderSessionInfo, renderSuggestions, renderModeSwitch, renderProviderSwitch, PROMPT, } from "./ui/banner.js";
13
13
  import { clearThinking, createThinkingStreamParser, getLastThinking, rememberThinkingFromText, renderThinkingBlock, renderThinkingSummary, renderThinkingToggleMessage, } from "./ui/thinking.js";
14
14
  import { createMarkdownStreamWriter, renderMarkdown } from "./ui/markdown.js";
15
15
  import { startThinkingSpinner } from "./ui/spinner.js";
16
16
  import { modelSupportsThinking } from "./llm/capabilities.js";
17
- import { toggleLastToolOutput } from "./ui/tool-output.js";
17
+ import { getLastViewport, getViewport, listViewports, toggleViewport, } from "./ui/output-pane.js";
18
+ import { compactMessages, estimateMessagesTokens, } from "./agent/context-manager.js";
19
+ import { isCtrlC, isCtrlO, isCtrlT, isEscape } from "./ui/keys.js";
18
20
  const slashCommands = [
19
21
  { command: "/ask", description: "switch to ask mode" },
20
22
  { command: "/agent", description: "switch to agent mode" },
@@ -50,10 +52,45 @@ const slashCommands = [
50
52
  { command: "/history", description: "show past sessions" },
51
53
  { command: "/save", usage: "<name>", description: "save session" },
52
54
  { command: "/cwd", usage: "<path>", description: "change working directory" },
53
- { command: "/allow", usage: "<tool>", description: "allow a tool for session" },
55
+ {
56
+ command: "/allow",
57
+ usage: "<tool>|list",
58
+ description: "allow a tool for this session (not persisted)",
59
+ },
60
+ {
61
+ command: "/disallow",
62
+ usage: "<tool>",
63
+ description: "revoke a session allow",
64
+ },
54
65
  { command: "/think", description: "show thinking from last response" },
55
66
  { command: "/thinking", description: "alias for /think" },
56
- { command: "/output", usage: "[last]", description: "toggle full output from last tool" },
67
+ {
68
+ command: "/output",
69
+ usage: "[last|<id>|list]",
70
+ description: "toggle full tool output (same as Ctrl+O)",
71
+ },
72
+ {
73
+ command: "/freeonly",
74
+ usage: "[on|off]",
75
+ description: "skip paid providers when fallback is enabled",
76
+ },
77
+ {
78
+ command: "/fallback",
79
+ usage: "[on|off]",
80
+ description: "try other configured providers after a failure (off by default)",
81
+ },
82
+ { command: "/compact", description: "compact session history now" },
83
+ { command: "/context", description: "show estimated context size" },
84
+ {
85
+ command: "/scope",
86
+ usage: "[show|clear|new|add <targets>]",
87
+ description: "manage pentest engagement scope",
88
+ },
89
+ {
90
+ command: "/privacy",
91
+ usage: "[status|clear-history|clear-logs|clear-artifacts|clear-all|on|off]",
92
+ description: "control retention and private mode (in-memory only)",
93
+ },
57
94
  { command: "/update", description: "check for updates" },
58
95
  { command: "/exit", description: "quit" },
59
96
  { command: "/quit", description: "alias for /exit" },
@@ -186,7 +223,9 @@ function isAbortLikeError(error) {
186
223
  return false;
187
224
  }
188
225
  function slashCommandLabel(command) {
189
- return command.usage ? `${command.command} ${command.usage}` : command.command;
226
+ return command.usage
227
+ ? `${command.command} ${command.usage}`
228
+ : command.command;
190
229
  }
191
230
  function slashCommandFilter(line) {
192
231
  if (!line.startsWith("/") || /\s/.test(line))
@@ -268,6 +307,18 @@ async function readPromptLine(options) {
268
307
  if (input.isTTY)
269
308
  input.setRawMode(false);
270
309
  };
310
+ const clearPromptDisplay = () => {
311
+ const previousMenuLines = renderedMenuLines;
312
+ cursorTo(output, 0);
313
+ clearLine(output, 0);
314
+ for (let i = 0; i < previousMenuLines; i += 1) {
315
+ output.write("\n");
316
+ clearLine(output, 0);
317
+ }
318
+ if (previousMenuLines > 0)
319
+ moveCursor(output, 0, -previousMenuLines);
320
+ renderedMenuLines = 0;
321
+ };
271
322
  const submit = (submittedLine) => {
272
323
  const previousMenuLines = renderedMenuLines;
273
324
  line = submittedLine;
@@ -294,7 +345,7 @@ async function readPromptLine(options) {
294
345
  // selecting + copying never breaks the REPL.
295
346
  if (key.meta && !key.ctrl && key.name === "c")
296
347
  return;
297
- if (key.ctrl && key.name === "c") {
348
+ if (isCtrlC(key)) {
298
349
  // First press: clear the current line. Second press within 1s: exit.
299
350
  // This mirrors bash / Claude Code and avoids killing the REPL by
300
351
  // accident when users habitually press Ctrl+C to copy in some
@@ -317,15 +368,15 @@ async function readPromptLine(options) {
317
368
  renderedMenuLines = 0;
318
369
  return;
319
370
  }
320
- if (key.ctrl && key.name === "t") {
371
+ if (isCtrlT(key)) {
321
372
  options.onThinkingShortcut();
322
373
  refresh();
323
374
  return;
324
375
  }
325
- if (key.ctrl && key.name === "o") {
376
+ if (isCtrlO(key)) {
377
+ clearPromptDisplay();
326
378
  output.write("\n");
327
- renderedMenuLines = 0;
328
- options.onOutputToggle();
379
+ void options.onOutputShortcut().finally(refresh);
329
380
  return;
330
381
  }
331
382
  if (key.name === "return" || key.name === "enter") {
@@ -342,7 +393,7 @@ async function readPromptLine(options) {
342
393
  }
343
394
  return;
344
395
  }
345
- if (key.name === "escape") {
396
+ if (isEscape(key)) {
346
397
  if (menu.visible) {
347
398
  dismissedSlashLine = line;
348
399
  refresh();
@@ -352,7 +403,8 @@ async function readPromptLine(options) {
352
403
  if (key.name === "up") {
353
404
  if (menu.visible && menu.suggestions.length > 0) {
354
405
  selectedIndex =
355
- (selectedIndex - 1 + menu.suggestions.length) % menu.suggestions.length;
406
+ (selectedIndex - 1 + menu.suggestions.length) %
407
+ menu.suggestions.length;
356
408
  refresh();
357
409
  return;
358
410
  }
@@ -517,7 +569,8 @@ async function withAbortableInput(run) {
517
569
  return await run(ac.signal);
518
570
  }
519
571
  catch (error) {
520
- if (ac.signal.aborted || (error instanceof Error && error.name === "AbortError")) {
572
+ if (ac.signal.aborted ||
573
+ (error instanceof Error && error.name === "AbortError")) {
521
574
  throw new AbortRunError();
522
575
  }
523
576
  throw error;
@@ -536,7 +589,8 @@ function help() {
536
589
  return ` ${chalk.cyan(label)}${chalk.dim(command.description)}`;
537
590
  })
538
591
  .join("\n");
539
- return lines + chalk.dim("\n\n ESC abort │ Ctrl+C clears input (twice to exit) │ Ctrl+T toggle thinking");
592
+ return (lines +
593
+ chalk.dim("\n\n ESC abort │ Ctrl+C clears input (twice to exit) │ Ctrl+T toggle thinking │ Ctrl+O / /output last toggle tool output"));
540
594
  }
541
595
  async function pickModelInteractively(provider, currentModel) {
542
596
  const models = knownModels[provider] ?? [];
@@ -771,17 +825,263 @@ async function handleSlash(line, state) {
771
825
  }
772
826
  case "/allow": {
773
827
  const tool = args[0];
774
- if (!tool)
775
- console.log(chalk.dim("usage: /allow <tool>"));
828
+ if (!tool) {
829
+ console.log(chalk.dim("usage: /allow <tool>|list"));
830
+ return true;
831
+ }
832
+ if (tool === "list" || tool === "ls") {
833
+ if (state.session.allow.size === 0) {
834
+ console.log(chalk.dim(" no session allows"));
835
+ }
836
+ else {
837
+ for (const allowed of state.session.allow) {
838
+ console.log(chalk.dim(` ✓ ${allowed}`));
839
+ }
840
+ }
841
+ return true;
842
+ }
843
+ state.session.allow.add(tool);
844
+ console.log(chalk.dim(` allowed ${tool} for this session only ✓`));
845
+ return true;
846
+ }
847
+ case "/disallow": {
848
+ const tool = args[0];
849
+ if (!tool) {
850
+ console.log(chalk.dim("usage: /disallow <tool>"));
851
+ return true;
852
+ }
853
+ if (state.session.allow.delete(tool)) {
854
+ console.log(chalk.dim(` revoked ${tool} ✓`));
855
+ }
776
856
  else {
777
- const config = getConfig();
778
- updateConfig({
779
- allowAlwaysTools: Array.from(new Set([...config.allowAlwaysTools, tool])),
780
- });
781
- console.log(chalk.dim(` allowed ${tool} ✓`));
857
+ console.log(chalk.dim(` ${tool} was not in the session allow list`));
782
858
  }
783
859
  return true;
784
860
  }
861
+ case "/context": {
862
+ const tokens = estimateMessagesTokens(state.messages);
863
+ console.log(chalk.dim(` ${state.messages.length} message(s), ~${tokens.toLocaleString()} tokens estimated`));
864
+ return true;
865
+ }
866
+ case "/compact": {
867
+ const before = state.messages.length;
868
+ const compacted = compactMessages(state.messages, { budgetTokens: 0 });
869
+ state.messages.splice(0, state.messages.length, ...compacted);
870
+ console.log(chalk.dim(` compacted ${before} → ${state.messages.length} messages (~${estimateMessagesTokens(state.messages).toLocaleString()} tokens)`));
871
+ return true;
872
+ }
873
+ case "/scope": {
874
+ const sub = (args[0] ?? "show").toLowerCase();
875
+ const { loadScope, saveScope, addScopeTargets, clearScope, isScopeActive, getScopePath, resetScopeCache, } = await import("./store/scope.js");
876
+ if (sub === "show" || sub === "ls" || sub === "list") {
877
+ resetScopeCache();
878
+ const scope = await loadScope();
879
+ if (!scope) {
880
+ console.log(chalk.dim(" no engagement scope configured"));
881
+ console.log(chalk.dim(` expected at: ${getScopePath()}`));
882
+ console.log(chalk.dim(" create one with: /scope add domain1,domain2 or `clai scope add --targets ...`"));
883
+ return true;
884
+ }
885
+ const status = isScopeActive(scope)
886
+ ? chalk.green("active")
887
+ : chalk.yellow("expired or empty");
888
+ console.log(chalk.dim(` scope: ${scope.name ?? "(unnamed)"} [${status}]`));
889
+ console.log(chalk.dim(` authorized: ${scope.authorizedTargets.join(", ")}`));
890
+ if (scope.excludedTargets && scope.excludedTargets.length > 0) {
891
+ console.log(chalk.dim(` excluded: ${scope.excludedTargets.join(", ")}`));
892
+ }
893
+ if (scope.expiresAt) {
894
+ console.log(chalk.dim(` expires: ${scope.expiresAt}`));
895
+ }
896
+ return true;
897
+ }
898
+ if (sub === "clear" || sub === "reset" || sub === "off") {
899
+ await clearScope();
900
+ console.log(chalk.dim(" engagement scope cleared"));
901
+ return true;
902
+ }
903
+ if (sub === "add") {
904
+ const rest = args.slice(1).join(" ").trim();
905
+ if (!rest) {
906
+ console.log(chalk.dim(" usage: /scope add <target1,target2,...>"));
907
+ return true;
908
+ }
909
+ const targets = rest
910
+ .split(/\s+/)[0]
911
+ .split(",")
912
+ .map((t) => t.trim())
913
+ .filter(Boolean);
914
+ if (targets.length === 0) {
915
+ console.log(chalk.dim(" no targets parsed"));
916
+ return true;
917
+ }
918
+ const scope = await addScopeTargets(targets);
919
+ console.log(chalk.dim(` added ${targets.length} target(s); scope now has ${scope.authorizedTargets.length}`));
920
+ return true;
921
+ }
922
+ if (sub === "new" || sub === "set") {
923
+ const rest = args.slice(1).join(" ").trim();
924
+ if (!rest) {
925
+ console.log(chalk.dim(" usage: /scope new <target1,target2,...> [name=<engagement>] [expires=<iso>]"));
926
+ return true;
927
+ }
928
+ // Parse: first whitespace-delimited token is the targets list,
929
+ // remaining `key=value` pairs configure name/expires/note.
930
+ const tokens = rest.split(/\s+/);
931
+ const targetsRaw = tokens[0] ?? "";
932
+ const targets = targetsRaw
933
+ .split(",")
934
+ .map((t) => t.trim())
935
+ .filter(Boolean);
936
+ if (targets.length === 0) {
937
+ console.log(chalk.dim(" no targets parsed"));
938
+ return true;
939
+ }
940
+ let name;
941
+ let expires;
942
+ let note;
943
+ let exclude;
944
+ for (const token of tokens.slice(1)) {
945
+ const eq = token.indexOf("=");
946
+ if (eq < 0)
947
+ continue;
948
+ const key = token.slice(0, eq).toLowerCase();
949
+ const value = token.slice(eq + 1);
950
+ if (key === "name")
951
+ name = value;
952
+ else if (key === "expires")
953
+ expires = value;
954
+ else if (key === "note")
955
+ note = value;
956
+ else if (key === "exclude")
957
+ exclude = value
958
+ .split(",")
959
+ .map((t) => t.trim())
960
+ .filter(Boolean);
961
+ }
962
+ const scope = {
963
+ name,
964
+ authorizedTargets: targets,
965
+ excludedTargets: exclude,
966
+ authorizationNote: note,
967
+ createdAt: new Date().toISOString(),
968
+ expiresAt: expires,
969
+ };
970
+ await saveScope(scope);
971
+ console.log(chalk.dim(` saved scope${name ? ` "${name}"` : ""} with ${targets.length} target(s)`));
972
+ return true;
973
+ }
974
+ console.log(chalk.dim(" usage: /scope [show|clear|new <targets>|add <targets> [key=value]...]"));
975
+ return true;
976
+ }
977
+ case "/privacy": {
978
+ const sub = (args[0] ?? "status").toLowerCase();
979
+ if (sub === "on" || sub === "enable") {
980
+ updateConfig({ privateMode: true });
981
+ console.log(chalk.dim(" privateMode: " +
982
+ chalk.green("on") +
983
+ " (history not written; in-memory only)"));
984
+ return true;
985
+ }
986
+ if (sub === "off" || sub === "disable") {
987
+ updateConfig({ privateMode: false });
988
+ console.log(chalk.dim(" privateMode: " + chalk.dim("off")));
989
+ return true;
990
+ }
991
+ if (sub === "status" || sub === "show") {
992
+ const cfg = getConfig();
993
+ console.log(chalk.dim(` privateMode: ${cfg.privateMode ? chalk.green("on") : chalk.dim("off")} retention: ${cfg.historyRetentionLimit || "unlimited"}`));
994
+ return true;
995
+ }
996
+ const { clearAllHistory } = await import("./store/history.js");
997
+ const { clearAuditLogs, clearArtifacts } = await import("./store/logs.js");
998
+ if (sub === "clear-history") {
999
+ const r = await clearAllHistory();
1000
+ console.log(chalk.dim(` history cleared (${r.detail || "ok"})`));
1001
+ return true;
1002
+ }
1003
+ if (sub === "clear-logs") {
1004
+ const r = await clearAuditLogs();
1005
+ console.log(chalk.dim(` audit logs cleared (${r.removed} files)`));
1006
+ return true;
1007
+ }
1008
+ if (sub === "clear-artifacts") {
1009
+ const r = await clearArtifacts();
1010
+ console.log(chalk.dim(` artifacts cleared (${r.removed} files)`));
1011
+ return true;
1012
+ }
1013
+ if (sub === "clear-all") {
1014
+ const a = await clearAllHistory();
1015
+ const b = await clearAuditLogs();
1016
+ const c = await clearArtifacts();
1017
+ console.log(chalk.dim(` history (${a.detail || "ok"}); logs (${b.removed}); artifacts (${c.removed})`));
1018
+ return true;
1019
+ }
1020
+ console.log(chalk.dim(" usage: /privacy [status|on|off|clear-history|clear-logs|clear-artifacts|clear-all]"));
1021
+ return true;
1022
+ }
1023
+ case "/freeonly": {
1024
+ const arg = (args[0] ?? "").toLowerCase().trim();
1025
+ if (!arg) {
1026
+ const value = getConfig().freeOnly;
1027
+ console.log(chalk.dim(` freeOnly: ${value ? chalk.green("on") : chalk.dim("off")} (applies when /fallback is on)`));
1028
+ return true;
1029
+ }
1030
+ if (arg === "on" || arg === "true" || arg === "enable") {
1031
+ updateConfig({ freeOnly: true });
1032
+ console.log(chalk.dim(" freeOnly: " + chalk.green("on")));
1033
+ return true;
1034
+ }
1035
+ if (arg === "off" || arg === "false" || arg === "disable") {
1036
+ updateConfig({ freeOnly: false });
1037
+ console.log(chalk.dim(" freeOnly: " + chalk.dim("off")));
1038
+ return true;
1039
+ }
1040
+ console.log(chalk.dim(" usage: /freeonly [on|off]"));
1041
+ return true;
1042
+ }
1043
+ case "/fallback": {
1044
+ const arg = (args[0] ?? "").toLowerCase().trim();
1045
+ if (!arg) {
1046
+ const value = getConfig().providerFallback;
1047
+ console.log(chalk.dim(` fallback: ${value ? chalk.green("on") : chalk.dim("off")} (selected provider/model only when off)`));
1048
+ return true;
1049
+ }
1050
+ if (arg === "on" || arg === "true" || arg === "enable") {
1051
+ updateConfig({ providerFallback: true });
1052
+ console.log(chalk.dim(" fallback: " + chalk.green("on")));
1053
+ return true;
1054
+ }
1055
+ if (arg === "off" || arg === "false" || arg === "disable") {
1056
+ updateConfig({ providerFallback: false });
1057
+ console.log(chalk.dim(" fallback: " + chalk.dim("off")));
1058
+ return true;
1059
+ }
1060
+ console.log(chalk.dim(" usage: /fallback [on|off]"));
1061
+ return true;
1062
+ }
1063
+ case "/output": {
1064
+ const target = args[0] ?? "last";
1065
+ if (target === "list" || target === "ls") {
1066
+ const all = listViewports();
1067
+ if (all.length === 0) {
1068
+ console.log(chalk.dim(" no tool outputs recorded yet"));
1069
+ }
1070
+ else {
1071
+ for (const v of all) {
1072
+ console.log(chalk.dim(` ${v.id} — ${v.toolName} ${v.argsDisplay}${v.artifactPath ? ` (${v.artifactPath})` : ""}`));
1073
+ }
1074
+ }
1075
+ return true;
1076
+ }
1077
+ const viewport = target === "last" ? getLastViewport() : getViewport(target);
1078
+ if (!viewport) {
1079
+ console.log(chalk.dim(` no viewport: ${target}`));
1080
+ return true;
1081
+ }
1082
+ await toggleViewport(viewport.id);
1083
+ return true;
1084
+ }
785
1085
  case "/think":
786
1086
  case "/thinking": {
787
1087
  const thinking = getLastThinking();
@@ -793,9 +1093,6 @@ async function handleSlash(line, state) {
793
1093
  }
794
1094
  return true;
795
1095
  }
796
- case "/output":
797
- await toggleLastToolOutput();
798
- return true;
799
1096
  case "/exit":
800
1097
  case "/quit":
801
1098
  return false;
@@ -812,15 +1109,19 @@ async function handleSlash(line, state) {
812
1109
  }
813
1110
  export async function startRepl(options = {}) {
814
1111
  const config = getConfig();
815
- const provider = options.provider ? assertProvider(options.provider) : config.defaultProvider;
1112
+ const provider = options.provider
1113
+ ? assertProvider(options.provider)
1114
+ : config.defaultProvider;
816
1115
  const state = {
817
1116
  mode: options.mode ?? config.defaultMode,
818
1117
  provider,
819
1118
  model: options.model ?? getProviderModel(provider),
820
1119
  messages: [],
1120
+ session: createSessionPolicy(),
821
1121
  };
822
1122
  const promptHistory = [];
823
1123
  let isReadingPrompt = false;
1124
+ let outputShortcutBusy = false;
824
1125
  emitKeypressEvents(input);
825
1126
  // Survive stray promise rejections (eg AbortError from a cancelled
826
1127
  // SSE reader) without killing the REPL. Anything that ends up here
@@ -847,16 +1148,33 @@ export async function startRepl(options = {}) {
847
1148
  const handleThinkingShortcut = () => {
848
1149
  process.stdout.write(`\n${renderThinkingToggleMessage()}\n`);
849
1150
  };
1151
+ const handleOutputShortcut = async () => {
1152
+ if (outputShortcutBusy)
1153
+ return;
1154
+ outputShortcutBusy = true;
1155
+ try {
1156
+ const v = getLastViewport();
1157
+ if (v) {
1158
+ if (currentAbortController)
1159
+ process.stdout.write("\n");
1160
+ await toggleViewport(v.id);
1161
+ }
1162
+ else {
1163
+ process.stdout.write(chalk.dim("\n (no tool output to expand yet)\n"));
1164
+ }
1165
+ }
1166
+ finally {
1167
+ outputShortcutBusy = false;
1168
+ }
1169
+ };
850
1170
  const handleKeypress = (_sequence, key) => {
851
- if (key.ctrl && key.name === "t" && !isReadingPrompt)
1171
+ if (isCtrlT(key) && !isReadingPrompt)
852
1172
  handleThinkingShortcut();
853
- if (key.ctrl && key.name === "o" && !isReadingPrompt) {
854
- void toggleLastToolOutput().catch((error) => {
855
- const message = error instanceof Error ? error.message : String(error);
856
- process.stderr.write(chalk.dim(`\n output toggle failed: ${message}\n`));
857
- });
1173
+ if (isCtrlO(key) && !isReadingPrompt) {
1174
+ void handleOutputShortcut();
858
1175
  }
859
- if ((key.name === "escape" || (key.ctrl && key.name === "c")) && currentAbortController) {
1176
+ if ((isEscape(key) || isCtrlC(key)) &&
1177
+ currentAbortController) {
860
1178
  currentAbortController.abort();
861
1179
  }
862
1180
  };
@@ -903,7 +1221,7 @@ export async function startRepl(options = {}) {
903
1221
  mode: state.mode,
904
1222
  }));
905
1223
  console.log(renderSuggestions());
906
- console.log(chalk.dim(" ESC to abort a response │ Ctrl+C clears input (twice to exit) │ Ctrl+T or /think for thinking\n"));
1224
+ console.log(chalk.dim(" ESC abort │ Ctrl+C clears input │ Ctrl+T or /think for thinking │ Ctrl+O or /output last for full tool output\n"));
907
1225
  // Hint thinking-capable users that the toggle exists. We default it to
908
1226
  // off for speed, since on NIM many models route through a much slower
909
1227
  // chat-template path when reasoning is enabled.
@@ -916,22 +1234,15 @@ export async function startRepl(options = {}) {
916
1234
  chalk.cyan("low|medium|high") +
917
1235
  chalk.dim(") to enable it.\n"));
918
1236
  }
919
- // Non-blocking update check when enabled.
920
- if (getConfig().autoUpdateCheck && process.env.CLAI_OFFLINE !== "1") {
921
- checkForUpdateSilent();
922
- }
1237
+ // Non-blocking update check
1238
+ checkForUpdateSilent();
923
1239
  try {
924
1240
  while (true) {
925
1241
  isReadingPrompt = true;
926
1242
  const line = (await readPromptLine({
927
1243
  history: promptHistory,
928
1244
  onThinkingShortcut: handleThinkingShortcut,
929
- onOutputToggle: () => {
930
- void toggleLastToolOutput().catch((error) => {
931
- const message = error instanceof Error ? error.message : String(error);
932
- process.stderr.write(chalk.dim(`\n output toggle failed: ${message}\n`));
933
- });
934
- },
1245
+ onOutputShortcut: handleOutputShortcut,
935
1246
  })).trim();
936
1247
  isReadingPrompt = false;
937
1248
  if (!line)
@@ -969,6 +1280,7 @@ export async function startRepl(options = {}) {
969
1280
  model: state.model,
970
1281
  history: state.messages,
971
1282
  signal,
1283
+ session: state.session,
972
1284
  }));
973
1285
  }
974
1286
  console.log();
@@ -992,7 +1304,13 @@ export async function startRepl(options = {}) {
992
1304
  if (siginfoRegistered)
993
1305
  process.off(siginfo, handleThinkingShortcut);
994
1306
  if (state.messages.length > 0) {
995
- await saveSession(state.messages, `repl-${new Date().toISOString()}`);
1307
+ // Honor `--no-history` and the persistent privateMode setting.
1308
+ // The session.allow set is already in-memory only; saveSession itself
1309
+ // also bails early when privateMode is on, but checking here keeps
1310
+ // intent obvious in the call site.
1311
+ if (!options.noHistory && !getConfig().privateMode) {
1312
+ await saveSession(state.messages, `repl-${new Date().toISOString()}`);
1313
+ }
996
1314
  }
997
1315
  if (input.isTTY)
998
1316
  input.setRawMode(false);