@vincemakes/kiso-code 0.1.15 → 0.1.17

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/index.js CHANGED
@@ -18,6 +18,8 @@
18
18
  import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
19
19
  import { createInterface } from "node:readline";
20
20
  import { Body } from "./body.js";
21
+ import { editFileDiff, writeFileDiff } from "./diff.js";
22
+ import { MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
21
23
  import { Editor, PROMPT as EDITOR_PROMPT } from "./editor.js";
22
24
  import { homedir, tmpdir } from "node:os";
23
25
  import { dirname, join } from "node:path";
@@ -25,19 +27,8 @@ import { fileURLToPath } from "node:url";
25
27
  import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, projectArtifacts, recordTrust, SessionStore, trustFor, } from "@vincemakes/kiso-runtime";
26
28
  import { createFauxProvider } from "@vincemakes/kiso-evals";
27
29
  import { createCodingTools } from "@vincemakes/kiso-tools-node";
28
- import { escapeTerminal, foldResult, foldThinking, palette, renderEvent, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, } from "./render.js";
30
+ import { escapeTerminal, foldResult, foldThinking, palette, renderEvent, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, bannerLines, kUnit, renderRecap, truncateRow, } from "./render.js";
29
31
  import { Dock } from "./dock.js";
30
- const PERMISSION_POLICY = {
31
- rules: [
32
- { tool: "read_file", action: "allow" },
33
- { tool: "list_dir", action: "allow" },
34
- { tool: "search_text", action: "allow" },
35
- { tool: "write_file", action: "defer" },
36
- { tool: "edit_file", action: "defer" },
37
- { tool: "shell", action: "defer" },
38
- ],
39
- default: "deny",
40
- };
41
32
  /** 发现#11: KISO_HOME is the ONE root — every default path derives from
42
33
  * it (sessions, trust, extensions, mcp config, skills). The dedicated
43
34
  * env vars (KISO_EXTENSIONS_DIR / KISO_MCP_CONFIG / KISO_SKILLS_DIR)
@@ -76,17 +67,18 @@ catch {
76
67
  * merges into the third row on TTY and stays a standalone line off-TTY.
77
68
  * v2a: the logo rows stay dim; the TAGLINE (row 2) is the blue identity
78
69
  * accent. */
79
- const LOGO_TOP = "█ █ ▀█▀ █▀▀ █▀█\n█▀▄ █ ▀▀█ █ █ ";
80
- const TAGLINE = "the coding agent that survives kill -9";
81
- const LOGO_BOTTOM = "\n▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀";
82
70
  function startupBanner() {
83
- // The historical `[N extensions: names]` text merges VERBATIM into the
84
- // third row the existing e2e assertions keep matching (天然不破). E3:
85
- // project-level extensions are counted in N and listed after `project:`
86
- // `[3 extensions: safe-defaults · project: lint-rules, mcp]`.
71
+ // v3 §01: the banner is block-split three independent logo rows
72
+ // (TOP / tagline / BOTTOM), then TWO info rows (version,
73
+ // extensions), each truncated at the window width; < 40 columns
74
+ // skips the logo. The historical `[N extensions: names]` text rides
75
+ // the extensions row verbatim (the e2e assertions keep matching).
87
76
  const p = palette();
88
- const names = bannerExtensionText();
89
- return `${p.dim}${LOGO_TOP}${p.blue}${TAGLINE}${p.reset}${p.dim}${LOGO_BOTTOM} v${VERSION}${names}${p.reset}\n`;
77
+ // A pty without a winsize reports columns = 0 (not undefined) — treat
78
+ // it as the default width, never as a 0-column truncation.
79
+ const W = process.stdout.columns ?? 0;
80
+ const rows = bannerLines(W > 0 ? W : 80, VERSION, bannerExtensionText().replace(/^ · /, ""));
81
+ return `${rows.map((r) => `${p.dim}${r}${p.reset}`).join("\n")}\n`;
90
82
  }
91
83
  /** v2a: the interactive prompt — blue, the identity accent. readline owns
92
84
  * the echo of what the user types; we own the prompt's color. (v2c: the
@@ -203,6 +195,7 @@ function makeLineInput() {
203
195
  editor.enter();
204
196
  const p = palette();
205
197
  dock.bindInput(() => editor.dockState(), `${p.blue}${EDITOR_PROMPT}${p.reset}`);
198
+ dock.bindMenu(() => editor.menuState()); // v3 §04: the slash-command menu
206
199
  return editorInput(editor);
207
200
  }
208
201
  return readlineInput(createInterface({ input: process.stdin, output: process.stdout }));
@@ -223,27 +216,19 @@ function bodyLog(text) {
223
216
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
224
217
  * is gone) — docked only, 200ms rotation between the request and the
225
218
  * first event. */
226
- function startStatusSpinner() {
219
+ function startStatusSpinner(onTick) {
227
220
  if (!dock.active)
228
221
  return () => { };
229
- const GLYPHS = ["◐", "◓", "◑", "◒"];
222
+ // v3 §03/§05: the working glyph family ▖▘▝▗, 200ms rotation — the
223
+ // callback repaints the running status line with the new glyph.
224
+ const GLYPHS = ["▖", "▘", "▝", "▗"];
230
225
  let i = 0;
231
- const timer = setInterval(() => dock.setTail(GLYPHS[i++ % GLYPHS.length]), 200);
226
+ const timer = setInterval(() => onTick(GLYPHS[i++ % GLYPHS.length]), 200);
232
227
  timer.unref();
233
- return () => dock.setTail("");
234
- }
235
- /** v2b: "running <tool> Ns" in the status bar while a tool executes. */
236
- function startRunningTimer(name) {
237
- if (!dock.active)
238
- return () => { };
239
- const started = Date.now();
240
- const timer = setInterval(() => dock.setTail(`running ${name} ${Math.round((Date.now() - started) / 1000)}s`), 1000);
241
- timer.unref();
242
- return () => {
243
- clearInterval(timer);
244
- dock.setTail("");
245
- };
228
+ return () => clearInterval(timer);
246
229
  }
230
+ /** v3 §03: "running <tool> Ns" is gone — the running status line owns
231
+ * the wall clock; the per-tool timer was the old tail mechanism. */
247
232
  /** The model name for the status bar — set by makeAgent. */
248
233
  let agentModel = "faux";
249
234
  /** E3: the `[N extensions: ...]` text — user-level names, then project-level
@@ -518,15 +503,26 @@ async function makeAgent(fauxSkipTurns = 0, input) {
518
503
  // Area 5: the coding tools are bound to the workspace — every path
519
504
  // they touch is canonicalized inside cwd, escapes are refused.
520
505
  tools: [...createCodingTools({ workspaceRoot: process.cwd() })],
521
- permissionPolicy: PERMISSION_POLICY,
522
- systemPrompt: composeSystemPrompt(process.cwd()),
506
+ // Modes: the five tiers ride the E1 policy chain (mode:<tier>
507
+ // extensions, current tier first) — the old static PERMISSION_POLICY
508
+ // is gone, its semantics live in the "default" tier. The banner
509
+ // still counts loadedExtensions only — the modes are in-process,
510
+ // never a file extension.
511
+ systemPrompt: (() => {
512
+ const sp = composeSystemPrompt(process.cwd());
513
+ const extra = modeSystemPrompt();
514
+ return extra === undefined ? sp : `${sp}\n\n${extra}`;
515
+ })(),
523
516
  // C 区: microcompact is ON by default in the product — threshold =
524
517
  // half the model window (KISO_CONTEXT_WINDOW override included;
525
518
  // 200k window → 100k tokens). Long sessions compact old read/list/
526
519
  // search/shell outputs instead of silently growing past the window.
527
520
  microcompact: { thresholdTokens: contextWindowTokens() / 2 },
528
521
  maxTurns: 20,
529
- extensions: loadedExtensions,
522
+ // Modes: the five tiers join at the CHAIN HEAD, before the user/
523
+ // project extensions (the deny>ask>allow composition keeps a user
524
+ // deny winning over any mode tier — bypass included).
525
+ extensions: [...modeExtensions(), ...loadedExtensions],
530
526
  ...(provider !== undefined
531
527
  ? {
532
528
  provider,
@@ -699,9 +695,46 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
699
695
  * line's form; `liveInput` (non-null only in interactive chat) carries the
700
696
  * last line THIS process's readline consumed — the double-echo filter.
701
697
  */
698
+ /** v2e: the approval-moment mini-diff — edit_file/write_file changes as
699
+ * ± lines; other tools get null (no diff, no cost). The file read is
700
+ * best-effort: an unreadable file yields NO diff, never a failure —
701
+ * the diff must never break the approval. */
702
+ function approvalDiff(name, input) {
703
+ if (name !== "edit_file" && name !== "write_file")
704
+ return null;
705
+ const path = typeof input.path === "string" ? input.path : "";
706
+ if (path === "")
707
+ return null;
708
+ let oldContent = null;
709
+ try {
710
+ oldContent = readFileSync(path, "utf8");
711
+ }
712
+ catch {
713
+ // a new write_file target (or an unreadable one) — all + degrades
714
+ }
715
+ try {
716
+ if (name === "edit_file") {
717
+ const search = typeof input.search === "string" ? input.search : "";
718
+ const replace = typeof input.replace === "string" ? input.replace : "";
719
+ if (search === "")
720
+ return null;
721
+ return editFileDiff(oldContent ?? "", search, replace);
722
+ }
723
+ const content = typeof input.content === "string" ? input.content : "";
724
+ return writeFileDiff(oldContent, content);
725
+ }
726
+ catch {
727
+ return null; // never let the diff break the approval
728
+ }
729
+ }
702
730
  async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
703
731
  let last;
704
732
  let usage = { in: null, out: null, cache: null, known: false };
733
+ // v3 §02: the recap line derives ENTIRELY from the local event stream
734
+ // (zero tokens) — wall seconds, tool/edit counts, usage, ctx left.
735
+ const turnStart = Date.now();
736
+ let toolCount = 0;
737
+ let editCount = 0;
705
738
  try {
706
739
  for await (const ev of run) {
707
740
  last = ev;
@@ -727,6 +760,9 @@ async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb
727
760
  body.thinkingAppend(ev.text);
728
761
  break;
729
762
  case "tool_call_end":
763
+ toolCount += 1;
764
+ if (ev.name === "edit_file")
765
+ editCount += 1;
730
766
  body.toolStart(ev.name, ev.callId, ev.input ?? {});
731
767
  break;
732
768
  case "tool_execution_started":
@@ -762,9 +798,12 @@ async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb
762
798
  case "permission_requested": {
763
799
  // v2d: the ToolCell shows the ⏸ badge; the question takes over
764
800
  // the dock status position; the answer lands at the input line.
765
- body.toolApproval(ev.callId);
766
- const decisionId = ev.decisionId;
801
+ // v2e: the mini-diff for edit/write at the approval moment —
802
+ // the human sees the change BEFORE deciding (auto-allowed tools
803
+ // skip the diff: nobody is looking).
767
804
  const name = ev.name;
805
+ body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
806
+ const decisionId = ev.decisionId;
768
807
  const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
769
808
  if (answer === CANCELLED) {
770
809
  // 十: a cancellation is a CONSERVATIVE denial, explicitly
@@ -776,13 +815,21 @@ async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb
776
815
  await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
777
816
  break;
778
817
  }
779
- case "terminal":
818
+ case "terminal": {
819
+ // v3 §02: the run's recap line REPLACES the old "done" label
820
+ // + status line — one local line, derived from this run's
821
+ // events (zero tokens). The dock's status bar still paints.
780
822
  statusCb?.(usage, estimateCtxRatio(session));
781
- // v2a rhythm: the honest label (\ndone\n — the completed
782
- // marker), the status line hugging it, then EXACTLY one blank
783
- // line before the next prompt.
784
- body.terminal(renderEvent(ev).text, renderStatusLine(turnNo, usage, estimateCtxRatio(session), faux) ?? "");
823
+ const ratio = estimateCtxRatio(session);
824
+ bodyLog(renderRecap({
825
+ seconds: Math.round((Date.now() - turnStart) / 1000),
826
+ tools: toolCount,
827
+ edits: editCount,
828
+ usage,
829
+ ctxLeftPct: Number.isFinite(ratio) ? (1 - ratio) * 100 : null,
830
+ }));
785
831
  break;
832
+ }
786
833
  default: {
787
834
  // Events without a cell (stop, …) — the generic render, byte-
788
835
  // preserved for the pipe path.
@@ -839,10 +886,20 @@ async function chat(session, faux, input) {
839
886
  currentRun = run;
840
887
  turnNo += 1;
841
888
  const myTurn = turnNo;
889
+ // v3 §03: the running state owns the status bar — the glyph
890
+ // rotates every 200ms; the idle state returns after the run.
891
+ runStart = Date.now();
892
+ runUsage = { in: null, out: null, cache: null, known: false };
893
+ const stopSpinner = startStatusSpinner((g) => {
894
+ runGlyph = g;
895
+ paintRunning();
896
+ });
842
897
  (async () => {
843
898
  let last;
844
899
  try {
845
900
  last = await consumeRun(session, run, input, myTurn, faux, liveInput, statusCb);
901
+ stopSpinner();
902
+ paintIdle();
846
903
  currentRun = null;
847
904
  // 八: a faux script that ran out of declared turns exits
848
905
  // loudly with a non-zero status — never a silent status 0.
@@ -925,13 +982,33 @@ async function chat(session, faux, input) {
925
982
  // v2c: turns submitted while another runs are QUEUED on the chain — the
926
983
  // live count rides the status bar (+N queued).
927
984
  let queued = 0;
928
- // v2b: the live status bar (docked only).
929
- const statusCb = (u, ctx) => {
985
+ // v2b: the live status bar (docked only). Modes: /mode switches repaint
986
+ // it immediately through paintStatus (the last turn stats are kept).
987
+ // v3 §03: the status bar has TWO states. Idle: the mode is ALWAYS
988
+ // shown (default included) with the /mode hint. Running: the working
989
+ // glyph (▖▘▝▗ — the spinner drives it) + wall seconds + ↓ out tokens
990
+ // + the interrupt hint. ctx left is the live estimate everywhere.
991
+ let runUsage = { in: null, out: null, cache: null, known: false };
992
+ let runGlyph = "▖";
993
+ let runStart = Date.now();
994
+ const paintRunning = () => {
930
995
  if (!dock.active)
931
996
  return;
932
- const st = renderStatusLine(turnNo, u, ctx, faux);
933
- const base = st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`;
934
- dock.setStatus(queued > 0 ? `${base} · +${queued} queued` : base);
997
+ const ratio = estimateCtxRatio(session);
998
+ const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
999
+ const out = runUsage.out !== null ? ` ${kUnit(runUsage.out)} tokens` : "";
1000
+ dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
1001
+ };
1002
+ const paintIdle = () => {
1003
+ if (!dock.active)
1004
+ return;
1005
+ const ratio = estimateCtxRatio(session);
1006
+ const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
1007
+ dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
1008
+ };
1009
+ const statusCb = (u, ctx) => {
1010
+ runUsage = u;
1011
+ paintRunning();
935
1012
  };
936
1013
  // The ONE dispatcher: slash commands, exit, and turns. The recovery
937
1014
  // replay routes through it too — a queued "/last" must never become a
@@ -949,6 +1026,7 @@ async function chat(session, faux, input) {
949
1026
  bodyLog(cmd("/think", "show the last full thinking block"));
950
1027
  bodyLog(cmd("/last", "show the most recent tool call's input and output"));
951
1028
  bodyLog(cmd("/status", "show session id, event count, and context estimate"));
1029
+ bodyLog(cmd("/mode", "show the approval tier; /mode <name> switches (manual/default/accept-edits/plan/bypass)"));
952
1030
  bodyLog(cmd("exit", "leave the session"));
953
1031
  input.prompt();
954
1032
  });
@@ -1002,6 +1080,29 @@ async function chat(session, faux, input) {
1002
1080
  });
1003
1081
  return;
1004
1082
  }
1083
+ if (trimmed === "/mode" || trimmed.startsWith("/mode ")) {
1084
+ // Modes: /mode alone prints the current tier + the list;
1085
+ // /mode <name> switches — the notice cell leaves the audit
1086
+ // line in the body, the status bar repaints at once.
1087
+ chain = chain.then(async () => {
1088
+ const m = MODES.find((x) => x === trimmed.slice(5).trim());
1089
+ if (trimmed.slice(5).trim() === "") {
1090
+ bodyLog(`mode ${getMode()}`);
1091
+ bodyLog(`tiers: ${MODES.join(" ")}`);
1092
+ }
1093
+ else if (m === undefined) {
1094
+ bodyLog(`no such mode: ${trimmed.slice(5).trim()}`);
1095
+ bodyLog(`tiers: ${MODES.join(" ")}`);
1096
+ }
1097
+ else {
1098
+ setMode(m);
1099
+ body.notice(`mode → ${m}`);
1100
+ paintIdle();
1101
+ }
1102
+ input.prompt();
1103
+ });
1104
+ return;
1105
+ }
1005
1106
  if (trimmed === "exit" || trimmed === "") {
1006
1107
  input.close();
1007
1108
  return;
@@ -1063,21 +1164,41 @@ async function resume(session, prompt, faux, input) {
1063
1164
  let currentRun = null;
1064
1165
  let cancelled = false;
1065
1166
  let turnNo = 0;
1066
- // v2b: the live status bar (docked only).
1167
+ // v3 §03: the two-state status bar (see chat — same shapes).
1168
+ let runUsage = { in: null, out: null, cache: null, known: false };
1169
+ let runGlyph = "▖";
1170
+ let runStart = Date.now();
1067
1171
  const statusCb = (u, ctx) => {
1172
+ runUsage = u;
1173
+ if (!dock.active)
1174
+ return;
1175
+ const pct = Number.isFinite(ctx) ? Math.round((1 - ctx) * 100) : null;
1176
+ const out = runUsage.out !== null ? ` ↓ ${kUnit(runUsage.out)} tokens` : "";
1177
+ dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
1178
+ };
1179
+ const paintIdle = () => {
1068
1180
  if (!dock.active)
1069
1181
  return;
1070
- const st = renderStatusLine(turnNo, u, ctx, faux);
1071
- dock.setStatus(st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`);
1182
+ const ratio = estimateCtxRatio(session);
1183
+ const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
1184
+ dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
1072
1185
  };
1073
1186
  const withRun = async (run) => {
1074
1187
  currentRun = run;
1188
+ runStart = Date.now();
1189
+ runUsage = { in: null, out: null, cache: null, known: false };
1190
+ const stopSpinner = startStatusSpinner((g) => {
1191
+ runGlyph = g;
1192
+ statusCb(runUsage, estimateCtxRatio(session));
1193
+ });
1075
1194
  try {
1076
1195
  turnNo += 1;
1077
1196
  const last = await consumeRun(session, run, input, turnNo, faux, null, statusCb);
1078
1197
  failOnFauxExhaustion(last, faux, input);
1079
1198
  }
1080
1199
  finally {
1200
+ stopSpinner();
1201
+ paintIdle();
1081
1202
  currentRun = null;
1082
1203
  }
1083
1204
  };
@@ -1127,7 +1248,24 @@ async function resume(session, prompt, faux, input) {
1127
1248
  }
1128
1249
  }
1129
1250
  async function main() {
1130
- const [command, arg] = process.argv.slice(2);
1251
+ // Modes: --mode <name> wins over KISO_MODE — both applied before the
1252
+ // first makeAgent (the tier extensions read `current` live). The flag
1253
+ // is stripped from the positional args, so it works in any position.
1254
+ const args = process.argv.slice(2);
1255
+ const modeFlag = args.indexOf("--mode");
1256
+ if (modeFlag !== -1) {
1257
+ const m = MODES.find((x) => x === args[modeFlag + 1]);
1258
+ if (m === undefined) {
1259
+ console.error(`unknown mode: ${args[modeFlag + 1]} (tiers: ${MODES.join(", ")})`);
1260
+ process.exit(2);
1261
+ }
1262
+ setMode(m);
1263
+ args.splice(modeFlag, 2);
1264
+ }
1265
+ else {
1266
+ setMode(modeFromEnv());
1267
+ }
1268
+ const [command, arg] = args;
1131
1269
  // 八: faux mode is the keyless demo script — an exhausted script must
1132
1270
  // exit non-zero, never masquerade as a successful provider run.
1133
1271
  const faux = process.env.ANTHROPIC_API_KEY === undefined && process.env.OPENAI_API_KEY === undefined;
@@ -1144,6 +1282,7 @@ async function main() {
1144
1282
  height: () => process.stdout.rows ?? 24,
1145
1283
  width: () => process.stdout.columns ?? 80,
1146
1284
  editCol: () => dock.editCol(),
1285
+ onDock: () => dock.redraw(), // v2d-B: the freeze scrolls the dock up — re-pin it
1147
1286
  });
1148
1287
  try {
1149
1288
  switch (command) {
@@ -1184,7 +1323,7 @@ async function main() {
1184
1323
  }
1185
1324
  case "help": {
1186
1325
  const p = palette();
1187
- console.log(`${p.dim}${LOGO_TOP}${p.blue}${TAGLINE}${p.reset}${p.dim}${LOGO_BOTTOM}${p.reset}\n\n` +
1326
+ console.log(`${p.dim}${bannerLines(80, VERSION, "").join("\n")}${p.reset}\n\n` +
1188
1327
  "kiso — the coding agent that survives kill -9\n\n" +
1189
1328
  " kiso [sessionId] interactive session (default command)\n" +
1190
1329
  " kiso chat [sessionId] same as above\n" +
package/dist/mode.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Modes — the five built-in approval tiers, built ON the E1 policy chain
3
+ * (the kernel is untouched). Each tier is an in-process "mode:<name>"
4
+ * extension whose decide() is live — it only speaks when it is the
5
+ * CURRENT tier (otherwise abstain = no opinion, ADR-0042), so /mode
6
+ * switches take effect immediately. The extension NAME rides the runtime's decidedBy
7
+ * field: an automated denial records decidedBy: "mode:<name>" — the
8
+ * audit sell. User-level extensions stay on the chain AFTER the mode
9
+ * tiers; a user deny always wins (the chain's deny>ask>allow
10
+ * monotonicity — bypass cannot override an extension deny).
11
+ */
12
+ import type { KisoExtension } from "@vincemakes/kiso-runtime";
13
+ export type Mode = "manual" | "default" | "accept-edits" | "plan" | "bypass";
14
+ export declare const MODES: readonly Mode[];
15
+ export declare function getMode(): Mode;
16
+ export declare function setMode(m: Mode): void;
17
+ /** The startup mode: KISO_MODE env (or the --mode flag — the CLI applies
18
+ * it before the first makeAgent). */
19
+ export declare function modeFromEnv(): Mode;
20
+ /** The five built-in mode tiers as chain extensions — named "mode:<tier>"
21
+ * so the runtime's decidedBy records exactly that (the runtime derives
22
+ * approvalPolicies from extensions[].approvals, tagging each with the
23
+ * extension name). The CURRENT tier is first: an all-allow chain records
24
+ * decidedBy = the FIRST SPEAKER, so an auto-allow under the startup mode
25
+ * names that mode honestly. Order never affects verdicts — the chain is
26
+ * deny>ask>allow over the SPEAKING verdicts (abstain = no opinion), so a
27
+ * user extension's deny wins over any mode tier, bypass included (the
28
+ * monotonicity e2e pins it). */
29
+ export declare function modeExtensions(): readonly KisoExtension[];
30
+ /** The plan tier's system prompt addition — injected at startup when the
31
+ * initial mode is plan (the session prompt is fixed at creation; runtime
32
+ * switches are guided by the deny reason). */
33
+ export declare function modeSystemPrompt(): string | undefined;
package/dist/mode.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Modes — the five built-in approval tiers, built ON the E1 policy chain
3
+ * (the kernel is untouched). Each tier is an in-process "mode:<name>"
4
+ * extension whose decide() is live — it only speaks when it is the
5
+ * CURRENT tier (otherwise abstain = no opinion, ADR-0042), so /mode
6
+ * switches take effect immediately. The extension NAME rides the runtime's decidedBy
7
+ * field: an automated denial records decidedBy: "mode:<name>" — the
8
+ * audit sell. User-level extensions stay on the chain AFTER the mode
9
+ * tiers; a user deny always wins (the chain's deny>ask>allow
10
+ * monotonicity — bypass cannot override an extension deny).
11
+ */
12
+ export const MODES = ["manual", "default", "accept-edits", "plan", "bypass"];
13
+ /** The read-only tool set (plan): reading is allowed, everything else
14
+ * denied with the guiding reason. */
15
+ const READ_TOOLS = new Set(["read_file", "list_dir", "search_text", "read_skill"]);
16
+ let current = "default";
17
+ export function getMode() {
18
+ return current;
19
+ }
20
+ export function setMode(m) {
21
+ current = m;
22
+ }
23
+ /** The startup mode: KISO_MODE env (or the --mode flag — the CLI applies
24
+ * it before the first makeAgent). */
25
+ export function modeFromEnv() {
26
+ const raw = process.env.KISO_MODE;
27
+ const m = MODES.find((x) => x === raw);
28
+ return m ?? "default";
29
+ }
30
+ /** The per-tier verdict for a tool call — only when this tier is current. */
31
+ function tierVerdict(tier, call) {
32
+ if (tier !== current)
33
+ return { action: "abstain" }; // not our tier — no opinion
34
+ switch (tier) {
35
+ case "manual":
36
+ return { action: "ask" }; // every tool asks
37
+ case "default":
38
+ if (READ_TOOLS.has(call.name))
39
+ return { action: "allow" };
40
+ if (call.name === "write_file" || call.name === "edit_file" || call.name === "shell")
41
+ return { action: "ask" };
42
+ // Abstain (ADR-0042): an extension-provided tool is the
43
+ // EXTENSIONS' business — the tier neither allows nor denies.
44
+ // The chain falls to the ask flow when nobody else speaks, so
45
+ // an uncovered external tool STILL meets the human (the P2
46
+ // finding: "allow"-as-no-opinion auto-approved it).
47
+ return { action: "abstain" };
48
+ case "accept-edits":
49
+ if (READ_TOOLS.has(call.name))
50
+ return { action: "allow" };
51
+ if (call.name === "write_file" || call.name === "edit_file")
52
+ return { action: "allow" };
53
+ if (call.name === "shell")
54
+ return { action: "ask" };
55
+ return { action: "abstain" }; // see "default"
56
+ case "plan":
57
+ if (READ_TOOLS.has(call.name))
58
+ return { action: "allow" };
59
+ return { action: "deny", reason: "plan mode: read-only" };
60
+ case "bypass":
61
+ return { action: "allow" }; // everything — a REAL allow, never an abstain
62
+ }
63
+ }
64
+ /** The five built-in mode tiers as chain extensions — named "mode:<tier>"
65
+ * so the runtime's decidedBy records exactly that (the runtime derives
66
+ * approvalPolicies from extensions[].approvals, tagging each with the
67
+ * extension name). The CURRENT tier is first: an all-allow chain records
68
+ * decidedBy = the FIRST SPEAKER, so an auto-allow under the startup mode
69
+ * names that mode honestly. Order never affects verdicts — the chain is
70
+ * deny>ask>allow over the SPEAKING verdicts (abstain = no opinion), so a
71
+ * user extension's deny wins over any mode tier, bypass included (the
72
+ * monotonicity e2e pins it). */
73
+ export function modeExtensions() {
74
+ return [...MODES.filter((m) => m === current), ...MODES.filter((m) => m !== current)].map((m) => ({
75
+ name: `mode:${m}`,
76
+ approvals: [
77
+ {
78
+ decide: async (payload) => tierVerdict(m, { name: payload.name }),
79
+ },
80
+ ],
81
+ }));
82
+ }
83
+ /** The plan tier's system prompt addition — injected at startup when the
84
+ * initial mode is plan (the session prompt is fixed at creation; runtime
85
+ * switches are guided by the deny reason). */
86
+ export function modeSystemPrompt() {
87
+ if (current !== "plan")
88
+ return undefined;
89
+ return ("plan mode: read-only. You may inspect the workspace (read_file, list_dir, search_text, " +
90
+ "read_skill) but every write/edit/shell call is DENIED with 'plan mode: read-only'. " +
91
+ "Produce a concrete plan (files, searches, proposed edits) as your output; the human " +
92
+ "switches to another mode to execute it.");
93
+ }
package/dist/render.d.ts CHANGED
@@ -11,11 +11,14 @@ import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
11
11
  * errors; dim for metadata. NO_COLOR set, or a non-TTY output → every
12
12
  * code is empty, so pipes and CI carry ZERO ANSI (the existing byte-level
13
13
  * e2e assertions guard it). Everything not listed here is plain.
14
+ * v3: `bg` — the user-message block background (SGR 48, dark gray 237).
14
15
  */
15
16
  export interface Palette {
16
17
  readonly blue: string;
17
18
  readonly dim: string;
18
19
  readonly red: string;
20
+ readonly green: string;
21
+ readonly bg: string;
19
22
  readonly reset: string;
20
23
  }
21
24
  export declare const COLOR_ON: Palette;
@@ -72,6 +75,8 @@ export declare function renderToolSummary(name: string, input: Record<string, un
72
75
  content: string;
73
76
  isError: boolean;
74
77
  }): string;
78
+ /** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
79
+ export declare function kUnit(value: number | null): string;
75
80
  /** B 区: usage data gathered from the run's usage events. */
76
81
  export interface RunUsage {
77
82
  readonly in: number | null;
@@ -95,6 +100,31 @@ export declare function renderStatusLine(turn: number, usage: RunUsage, ctxRatio
95
100
  * this verbatim; the render tests pin the sequence.
96
101
  */
97
102
  export declare function renderTerminalGap(statusLine: string | null): string;
103
+ /**
104
+ * v3 §01 — the banner, block-split. The logo is three INDEPENDENT rows
105
+ * (TOP / the tagline / BOTTOM), then TWO info rows (version,
106
+ * extensions). Every row truncates at the terminal width with a " (+N)"
107
+ * marker (N = the hidden display width); a window narrower than 40
108
+ * columns skips the logo entirely — only the info rows. Pure.
109
+ */
110
+ export declare const TAGLINE = "the coding agent that survives kill -9";
111
+ /** v3 §01: truncate a row at `width`, marking the hidden span " (+N)". */
112
+ export declare function truncateRow(row: string, width: number): string;
113
+ /** v3 §01: the banner lines for a width W — logo (skipped under 40
114
+ * columns) + version + extensions. */
115
+ export declare function bannerLines(W: number, version: string, extensionsText: string): string[];
116
+ /** v3 §02 — the recap line that ends a run, replacing the "done" label +
117
+ * the old status line. All fields derive LOCALLY from the event stream
118
+ * (zero tokens): wall seconds, tool counts, usage, cache hit %, ctx left.
119
+ */
120
+ export interface RecapStats {
121
+ readonly seconds: number;
122
+ readonly tools: number;
123
+ readonly edits: number;
124
+ readonly usage: RunUsage;
125
+ readonly ctxLeftPct: number | null;
126
+ }
127
+ export declare function renderRecap(s: RecapStats): string;
98
128
  /** One-line summary of a session, for `kiso sessions`. */
99
129
  export declare function renderSessionLine(meta: {
100
130
  id: string;