@vincemakes/kiso-code 0.1.14 → 0.1.16

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
@@ -17,6 +17,9 @@
17
17
  */
18
18
  import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
19
19
  import { createInterface } from "node:readline";
20
+ import { Body } from "./body.js";
21
+ import { editFileDiff, writeFileDiff } from "./diff.js";
22
+ import { MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
20
23
  import { Editor, PROMPT as EDITOR_PROMPT } from "./editor.js";
21
24
  import { homedir, tmpdir } from "node:os";
22
25
  import { dirname, join } from "node:path";
@@ -26,17 +29,6 @@ import { createFauxProvider } from "@vincemakes/kiso-evals";
26
29
  import { createCodingTools } from "@vincemakes/kiso-tools-node";
27
30
  import { escapeTerminal, foldResult, foldThinking, palette, renderEvent, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, } from "./render.js";
28
31
  import { Dock } from "./dock.js";
29
- const PERMISSION_POLICY = {
30
- rules: [
31
- { tool: "read_file", action: "allow" },
32
- { tool: "list_dir", action: "allow" },
33
- { tool: "search_text", action: "allow" },
34
- { tool: "write_file", action: "defer" },
35
- { tool: "edit_file", action: "defer" },
36
- { tool: "shell", action: "defer" },
37
- ],
38
- default: "deny",
39
- };
40
32
  /** 发现#11: KISO_HOME is the ONE root — every default path derives from
41
33
  * it (sessions, trust, extensions, mcp config, skills). The dedicated
42
34
  * env vars (KISO_EXTENSIONS_DIR / KISO_MCP_CONFIG / KISO_SKILLS_DIR)
@@ -209,17 +201,15 @@ function makeLineInput() {
209
201
  /** v2b: the bottom-anchored UI — docked only on a color TTY; pipes and
210
202
  * NO_COLOR stay the v2a line mode byte-for-byte. */
211
203
  const dock = new Dock();
212
- /** v2b: body output routes through the dock when docked (the cursor into
213
- * the scroll region, then back to the input line); otherwise a plain
214
- * write pipes are byte-identical to v2a. */
215
- function bodyWrite(text) {
216
- if (dock.active)
217
- dock.writeBody(text);
218
- else
219
- process.stdout.write(text);
220
- }
204
+ /** v2d: the body renderer the ONE writer of the stdout scroll region
205
+ * (the frozen area + the active tail). Pipes run it in passthrough (the
206
+ * v2b/v2c line-mode bytes, byte-for-byte). Created in main; closed on
207
+ * every exit path. */
208
+ let body;
209
+ /** v2d: body output routes through the cell renderer — the single writer.
210
+ * bodyLog adds the trailing newline; internal newlines are preserved. */
221
211
  function bodyLog(text) {
222
- bodyWrite(`${text}\n`);
212
+ body.raw(text.split("\n"));
223
213
  }
224
214
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
225
215
  * is gone) — docked only, 200ms rotation between the request and the
@@ -261,6 +251,21 @@ function bannerExtensionText() {
261
251
  parts.push(`project: ${projectExtensions.map((e) => e.name).join(", ")}`);
262
252
  return ` · [${total} extension${total === 1 ? "" : "s"}: ${parts.join(" · ")}]`;
263
253
  }
254
+ /** Modes: the status-bar indicator — the default tier shows nothing; the
255
+ * others show their blue name, the dangerous ones (plan/bypass) with the
256
+ * ⚠ prefix. */
257
+ function modeStatusText() {
258
+ if (getMode() === "default")
259
+ return "";
260
+ const p = palette();
261
+ const danger = getMode() === "plan" || getMode() === "bypass" ? "⚠ " : "";
262
+ return `${p.blue}${danger}${getMode()}${p.reset}`;
263
+ }
264
+ /** Modes: append the mode indicator to a composed status base. */
265
+ function statusWithMode(base) {
266
+ const mode = modeStatusText();
267
+ return mode === "" ? base : `${base} · ${mode}`;
268
+ }
264
269
  /** E1: the startup banner line(s) — TTY: logo + merged extensions; off-TTY:
265
270
  * the historical `[N extensions: ...]` standalone line (zero change). */
266
271
  function extensionsBanner() {
@@ -519,15 +524,26 @@ async function makeAgent(fauxSkipTurns = 0, input) {
519
524
  // Area 5: the coding tools are bound to the workspace — every path
520
525
  // they touch is canonicalized inside cwd, escapes are refused.
521
526
  tools: [...createCodingTools({ workspaceRoot: process.cwd() })],
522
- permissionPolicy: PERMISSION_POLICY,
523
- systemPrompt: composeSystemPrompt(process.cwd()),
527
+ // Modes: the five tiers ride the E1 policy chain (mode:<tier>
528
+ // extensions, current tier first) — the old static PERMISSION_POLICY
529
+ // is gone, its semantics live in the "default" tier. The banner
530
+ // still counts loadedExtensions only — the modes are in-process,
531
+ // never a file extension.
532
+ systemPrompt: (() => {
533
+ const sp = composeSystemPrompt(process.cwd());
534
+ const extra = modeSystemPrompt();
535
+ return extra === undefined ? sp : `${sp}\n\n${extra}`;
536
+ })(),
524
537
  // C 区: microcompact is ON by default in the product — threshold =
525
538
  // half the model window (KISO_CONTEXT_WINDOW override included;
526
539
  // 200k window → 100k tokens). Long sessions compact old read/list/
527
540
  // search/shell outputs instead of silently growing past the window.
528
541
  microcompact: { thresholdTokens: contextWindowTokens() / 2 },
529
542
  maxTurns: 20,
530
- extensions: loadedExtensions,
543
+ // Modes: the five tiers join at the CHAIN HEAD, before the user/
544
+ // project extensions (the deny>ask>allow composition keeps a user
545
+ // deny winning over any mode tier — bypass included).
546
+ extensions: [...modeExtensions(), ...loadedExtensions],
531
547
  ...(provider !== undefined
532
548
  ? {
533
549
  provider,
@@ -700,43 +716,48 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
700
716
  * line's form; `liveInput` (non-null only in interactive chat) carries the
701
717
  * last line THIS process's readline consumed — the double-echo filter.
702
718
  */
703
- async function consumeRun(session, run, input, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb) {
719
+ /** v2e: the approval-moment mini-diff edit_file/write_file changes as
720
+ * ± lines; other tools get null (no diff, no cost). The file read is
721
+ * best-effort: an unreadable file yields NO diff, never a failure —
722
+ * the diff must never break the approval. */
723
+ function approvalDiff(name, input) {
724
+ if (name !== "edit_file" && name !== "write_file")
725
+ return null;
726
+ const path = typeof input.path === "string" ? input.path : "";
727
+ if (path === "")
728
+ return null;
729
+ let oldContent = null;
730
+ try {
731
+ oldContent = readFileSync(path, "utf8");
732
+ }
733
+ catch {
734
+ // a new write_file target (or an unreadable one) — all + degrades
735
+ }
736
+ try {
737
+ if (name === "edit_file") {
738
+ const search = typeof input.search === "string" ? input.search : "";
739
+ const replace = typeof input.replace === "string" ? input.replace : "";
740
+ if (search === "")
741
+ return null;
742
+ return editFileDiff(oldContent ?? "", search, replace);
743
+ }
744
+ const content = typeof input.content === "string" ? input.content : "";
745
+ return writeFileDiff(oldContent, content);
746
+ }
747
+ catch {
748
+ return null; // never let the diff break the approval
749
+ }
750
+ }
751
+ async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
704
752
  let last;
705
- // B 区: tool_call_end → (name, input) for the summary; tool_result →
706
- // one summary line. Usage events feed the status line.
707
- const pendingCalls = new Map();
708
753
  let usage = { in: null, out: null, cache: null, known: false };
709
- // v2b: thinking blocks buffer and fold to ONE dim line at the block's
710
- // end (foldThinking); the FULL text goes to /think.
711
- let thinkingBuf = "";
712
- const flushThinking = () => {
713
- if (thinkingBuf === "")
714
- return;
715
- lastThinking.current = thinkingBuf;
716
- bodyWrite(foldThinking(thinkingBuf));
717
- thinkingBuf = "";
718
- };
719
- let thinkingOpen = false;
720
- // v2b: liveness merged into the status bar (docked); a running timer
721
- // shows "running <tool> Ns" during a tool execution.
722
- const stopSpinner = startStatusSpinner();
723
- let stopRunning = null;
724
- let firstEvent = true;
725
754
  try {
726
755
  for await (const ev of run) {
727
- if (firstEvent) {
728
- firstEvent = false;
729
- stopSpinner();
730
- }
731
756
  last = ev;
732
- // v2a (双回显): the interactive readline already echoed an input THIS
733
- // process consumed — rendering the event again is the double echo.
734
- // Replayed history (recovery/resume — nobody typed) keeps the event
735
- // render. Deterministic: exact content match with the consumed line,
736
- // on a TTY (the only place an echo exists to hand over).
757
+ // v2a (双回显): the interactive echo was already rendered by the
758
+ // input source — rendering the event again is the double echo.
737
759
  // v2b: DOCKED — the echo lives in the input row (H), NOT the body;
738
- // the body render is the ONLY visible copy of the sent line. The
739
- // user's typed text must not vanish after Enter.
760
+ // the body render is the ONLY visible copy of the sent line.
740
761
  if (ev.type === "user_input" &&
741
762
  liveInput !== null &&
742
763
  liveInput.current === (typeof ev.content === "string" ? ev.content : "") &&
@@ -744,78 +765,91 @@ async function consumeRun(session, run, input, turnNo, lastToolRef, faux, liveIn
744
765
  !dock.active) {
745
766
  continue;
746
767
  }
747
- const prevThinking = thinkingOpen;
748
- thinkingOpen = ev.type === "thinking";
749
- if (prevThinking && !thinkingOpen)
750
- flushThinking();
751
- if (ev.type === "thinking") {
752
- thinkingBuf += ev.text;
753
- continue;
754
- }
755
- if (ev.type === "tool_call_end") {
756
- pendingCalls.set(ev.callId, { name: ev.name, input: ev.input ?? {} });
757
- }
758
- if (ev.type === "tool_execution_started") {
759
- stopRunning = startRunningTimer(ev.name);
760
- }
761
- if (ev.type === "tool_result") {
762
- stopRunning?.();
763
- stopRunning = null;
764
- const call = pendingCalls.get(ev.callId);
765
- pendingCalls.delete(ev.callId);
766
- if (call !== undefined) {
768
+ // v2d: EVERY event only mutates a cell — the Body is the single
769
+ // writer of the scroll region, so interleaving is impossible by
770
+ // construction (ADR-0040).
771
+ switch (ev.type) {
772
+ case "user_input":
773
+ body.userLine(typeof ev.content === "string" ? ev.content : "");
774
+ break;
775
+ case "thinking":
776
+ body.thinkingAppend(ev.text);
777
+ break;
778
+ case "tool_call_end":
779
+ body.toolStart(ev.name, ev.callId, ev.input ?? {});
780
+ break;
781
+ case "tool_execution_started":
782
+ body.toolRunning(ev.callId);
783
+ break;
784
+ case "tool_execution_succeeded":
785
+ body.toolSucceeded(ev.callId);
786
+ break;
787
+ case "tool_execution_failed":
788
+ body.toolFailed(ev.callId, ev.error);
789
+ break;
790
+ case "tool_result": {
767
791
  const text = typeof ev.content === "string" ? ev.content : "";
768
- lastToolRef.current = { name: call.name, input: call.input, result: { content: text, isError: ev.isError } };
769
- bodyLog(renderToolSummary(call.name, call.input, { content: text, isError: ev.isError }));
792
+ body.toolResult(ev.callId, { content: text, isError: ev.isError });
793
+ break;
770
794
  }
771
- }
772
- if (ev.type === "usage") {
773
- usage = { in: ev.inputTokens, out: ev.outputTokens, cache: ev.cacheRead, known: ev.known };
774
- statusCb?.(usage, estimateCtxRatio(session));
775
- }
776
- if (ev.type === "uncertain_pending") {
777
- // 裁决 #12 (ADR-0038): the ⚠ line is pure INFORMATION now — the
778
- // approval chain guards retries, and the human question belongs
779
- // only to the crash window's recovery flow (resolveUncertains).
780
- // Old logs may still carry the event; replay shows the fact.
781
- bodyLog(`\n⚠ ${escapeTerminal(ev.name)} FAILED — the side effect may have applied.\n ${escapeTerminal(ev.error)}\n`);
782
- continue;
783
- }
784
- const rendered = renderEvent(ev, prevThinking);
785
- if (rendered.prompt) {
786
- // v2b (docked): the detail scrolls into the body; the question
787
- // takes over the status position; the answer lands at the input
788
- // line. Pipes keep the v2a inline render.
789
- bodyWrite(rendered.text);
790
- const decisionId = ev.decisionId;
791
- const name = ev.name;
792
- // 八: the tool name is model text — escaped on every output path.
793
- const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
794
- if (answer === CANCELLED) {
795
- // 十: a cancellation is a CONSERVATIVE denial, explicitly
796
- // distinguished from the user typing "n".
797
- bodyLog("[approval cancelled treated as a denial]\n");
798
- await session.approve(decisionId, false);
799
- continue;
795
+ case "text_delta":
796
+ body.textAppend(ev.text);
797
+ break;
798
+ case "text_end":
799
+ body.textEnd();
800
+ break;
801
+ case "usage":
802
+ usage = { in: ev.inputTokens, out: ev.outputTokens, cache: ev.cacheRead, known: ev.known };
803
+ statusCb?.(usage, estimateCtxRatio(session));
804
+ break;
805
+ case "uncertain_pending":
806
+ // 裁决 #12 (ADR-0038): the ⚠ line is pure INFORMATION now — the
807
+ // approval chain guards retries, and the human question belongs
808
+ // only to the crash window's recovery flow (resolveUncertains).
809
+ body.notice(`⚠ ${escapeTerminal(ev.name)} FAILED — the side effect may have applied. ${escapeTerminal(ev.error)}`);
810
+ break;
811
+ case "permission_requested": {
812
+ // v2d: the ToolCell shows the badge; the question takes over
813
+ // the dock status position; the answer lands at the input line.
814
+ // v2e: the mini-diff for edit/write at the approval moment —
815
+ // the human sees the change BEFORE deciding (auto-allowed tools
816
+ // skip the diff: nobody is looking).
817
+ const name = ev.name;
818
+ body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
819
+ const decisionId = ev.decisionId;
820
+ const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
821
+ if (answer === CANCELLED) {
822
+ // 十: a cancellation is a CONSERVATIVE denial, explicitly
823
+ // distinguished from the user typing "n".
824
+ body.notice("[approval cancelled — treated as a denial]");
825
+ await session.approve(decisionId, false);
826
+ continue;
827
+ }
828
+ await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
829
+ break;
830
+ }
831
+ case "terminal":
832
+ statusCb?.(usage, estimateCtxRatio(session));
833
+ statusCb?.(usage, estimateCtxRatio(session));
834
+ // v2a rhythm: the honest label (\ndone\n — the completed
835
+ // marker), the status line hugging it, then EXACTLY one blank
836
+ // line before the next prompt.
837
+ body.terminal(renderEvent(ev).text, renderStatusLine(turnNo, usage, estimateCtxRatio(session), faux) ?? "");
838
+ break;
839
+ default: {
840
+ // Events without a cell (stop, …) — the generic render, byte-
841
+ // preserved for the pipe path.
842
+ const rendered = renderEvent(ev);
843
+ if (rendered.text !== "") {
844
+ body.raw(rendered.text.replace(/\n$/, "").split("\n"));
845
+ }
846
+ break;
800
847
  }
801
- await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
802
- }
803
- else {
804
- bodyWrite(rendered.text);
805
- }
806
- if (ev.type === "terminal") {
807
- statusCb?.(usage, estimateCtxRatio(session));
808
- // v2a rhythm: the status line hugs the terminal (有什么显什么 —
809
- // null = nothing to show), then EXACTLY one blank line before
810
- // the next prompt.
811
- bodyWrite(renderTerminalGap(renderStatusLine(turnNo, usage, estimateCtxRatio(session), faux)));
812
848
  }
813
849
  }
814
- flushThinking();
850
+ body.thinkingEnd(); // a trailing thinking block folds at the run's end
815
851
  }
816
852
  finally {
817
- stopSpinner();
818
- stopRunning?.();
819
853
  }
820
854
  return last;
821
855
  }
@@ -861,7 +895,7 @@ async function chat(session, faux, input) {
861
895
  (async () => {
862
896
  let last;
863
897
  try {
864
- last = await consumeRun(session, run, input, myTurn, lastToolRef, faux, liveInput, lastThinking, statusCb);
898
+ last = await consumeRun(session, run, input, myTurn, faux, liveInput, statusCb);
865
899
  currentRun = null;
866
900
  // 八: a faux script that ran out of declared turns exits
867
901
  // loudly with a non-zero status — never a silent status 0.
@@ -935,24 +969,29 @@ async function chat(session, faux, input) {
935
969
  let chain = Promise.resolve();
936
970
  let replReady = false;
937
971
  const queuedLines = [];
938
- // B 区: user-turn counter for the status line, and the /last buffer.
972
+ // B 区: user-turn counter for the status line. /last and /think read
973
+ // the body (the ToolCell / ThinkingCell final states).
939
974
  let turnNo = 0;
940
- const lastToolRef = { current: null };
941
975
  // v2a: the last line THIS process's readline consumed — the double-echo
942
976
  // filter (see consumeRun). Only interactive chat sets it.
943
977
  const liveInput = { current: null };
944
- // v2b: the last complete thinking block, for /think.
945
- const lastThinking = { current: null };
946
978
  // v2c: turns submitted while another runs are QUEUED on the chain — the
947
979
  // live count rides the status bar (+N queued).
948
980
  let queued = 0;
949
- // v2b: the live status bar (docked only).
981
+ // v2b: the live status bar (docked only). Modes: /mode switches repaint
982
+ // it immediately through paintStatus (the last turn stats are kept).
983
+ let statusSt = null;
950
984
  const statusCb = (u, ctx) => {
951
985
  if (!dock.active)
952
986
  return;
953
- const st = renderStatusLine(turnNo, u, ctx, faux);
954
- const base = st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`;
955
- dock.setStatus(queued > 0 ? `${base} · +${queued} queued` : base);
987
+ statusSt = renderStatusLine(turnNo, u, ctx, faux);
988
+ paintStatus();
989
+ };
990
+ const paintStatus = () => {
991
+ if (!dock.active)
992
+ return;
993
+ const base = statusSt === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${statusSt}`;
994
+ dock.setStatus(`${statusWithMode(base)}${queued > 0 ? ` · +${queued} queued` : ""}`);
956
995
  };
957
996
  // The ONE dispatcher: slash commands, exit, and turns. The recovery
958
997
  // replay routes through it too — a queued "/last" must never become a
@@ -970,16 +1009,17 @@ async function chat(session, faux, input) {
970
1009
  bodyLog(cmd("/think", "show the last full thinking block"));
971
1010
  bodyLog(cmd("/last", "show the most recent tool call's input and output"));
972
1011
  bodyLog(cmd("/status", "show session id, event count, and context estimate"));
1012
+ bodyLog(cmd("/mode", "show the approval tier; /mode <name> switches (manual/default/accept-edits/plan/bypass)"));
973
1013
  bodyLog(cmd("exit", "leave the session"));
974
1014
  input.prompt();
975
1015
  });
976
1016
  return;
977
1017
  }
978
1018
  if (trimmed === "/think") {
979
- // v2b: print the last COMPLETE thinking block — straight from the
980
- // event stream, nothing stored separately (the /last pattern).
1019
+ // v2b/v2d: print the last COMPLETE thinking block — the body holds
1020
+ // it (the ThinkingCell's fold closes at the block's end).
981
1021
  chain = chain.then(async () => {
982
- const t = lastThinking.current;
1022
+ const t = body.lastThinking();
983
1023
  if (t === null) {
984
1024
  bodyLog("[no thinking yet]");
985
1025
  }
@@ -991,11 +1031,11 @@ async function chat(session, faux, input) {
991
1031
  return;
992
1032
  }
993
1033
  if (trimmed === "/last") {
994
- // B 区: print the FULL input/output of the most recent tool call,
995
- // straight from the event stream nothing is stored separately.
996
- // Runs on the chain: after any in-flight turn completes.
1034
+ // B 区/v2d: print the FULL input/output of the most recent tool
1035
+ // call the body holds it (the ToolCell's final state). Runs on
1036
+ // the chain: after any in-flight turn completes.
997
1037
  chain = chain.then(async () => {
998
- const tool = lastToolRef.current;
1038
+ const tool = body.lastTool();
999
1039
  if (tool === null) {
1000
1040
  bodyLog("[no tool call yet]");
1001
1041
  }
@@ -1023,6 +1063,29 @@ async function chat(session, faux, input) {
1023
1063
  });
1024
1064
  return;
1025
1065
  }
1066
+ if (trimmed === "/mode" || trimmed.startsWith("/mode ")) {
1067
+ // Modes: /mode alone prints the current tier + the list;
1068
+ // /mode <name> switches — the notice cell leaves the audit
1069
+ // line in the body, the status bar repaints at once.
1070
+ chain = chain.then(async () => {
1071
+ const m = MODES.find((x) => x === trimmed.slice(5).trim());
1072
+ if (trimmed.slice(5).trim() === "") {
1073
+ bodyLog(`mode ${getMode()}`);
1074
+ bodyLog(`tiers: ${MODES.join(" ")}`);
1075
+ }
1076
+ else if (m === undefined) {
1077
+ bodyLog(`no such mode: ${trimmed.slice(5).trim()}`);
1078
+ bodyLog(`tiers: ${MODES.join(" ")}`);
1079
+ }
1080
+ else {
1081
+ setMode(m);
1082
+ body.notice(`mode → ${m}`);
1083
+ paintStatus();
1084
+ }
1085
+ input.prompt();
1086
+ });
1087
+ return;
1088
+ }
1026
1089
  if (trimmed === "exit" || trimmed === "") {
1027
1090
  input.close();
1028
1091
  return;
@@ -1049,7 +1112,7 @@ async function chat(session, faux, input) {
1049
1112
  const recoveryRun = session.resume();
1050
1113
  currentRun = recoveryRun;
1051
1114
  turnNo += 1;
1052
- const last = await consumeRun(session, recoveryRun, input, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb);
1115
+ const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
1053
1116
  currentRun = null;
1054
1117
  failOnFauxExhaustion(last, faux, input);
1055
1118
  }
@@ -1084,20 +1147,19 @@ async function resume(session, prompt, faux, input) {
1084
1147
  let currentRun = null;
1085
1148
  let cancelled = false;
1086
1149
  let turnNo = 0;
1087
- const lastToolRef = { current: null };
1088
- const lastThinking = { current: null };
1089
1150
  // v2b: the live status bar (docked only).
1090
1151
  const statusCb = (u, ctx) => {
1091
1152
  if (!dock.active)
1092
1153
  return;
1093
1154
  const st = renderStatusLine(turnNo, u, ctx, faux);
1094
- dock.setStatus(st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`);
1155
+ const base = st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`;
1156
+ dock.setStatus(statusWithMode(base));
1095
1157
  };
1096
1158
  const withRun = async (run) => {
1097
1159
  currentRun = run;
1098
1160
  try {
1099
1161
  turnNo += 1;
1100
- const last = await consumeRun(session, run, input, turnNo, lastToolRef, faux, null, lastThinking, statusCb);
1162
+ const last = await consumeRun(session, run, input, turnNo, faux, null, statusCb);
1101
1163
  failOnFauxExhaustion(last, faux, input);
1102
1164
  }
1103
1165
  finally {
@@ -1150,7 +1212,24 @@ async function resume(session, prompt, faux, input) {
1150
1212
  }
1151
1213
  }
1152
1214
  async function main() {
1153
- const [command, arg] = process.argv.slice(2);
1215
+ // Modes: --mode <name> wins over KISO_MODE — both applied before the
1216
+ // first makeAgent (the tier extensions read `current` live). The flag
1217
+ // is stripped from the positional args, so it works in any position.
1218
+ const args = process.argv.slice(2);
1219
+ const modeFlag = args.indexOf("--mode");
1220
+ if (modeFlag !== -1) {
1221
+ const m = MODES.find((x) => x === args[modeFlag + 1]);
1222
+ if (m === undefined) {
1223
+ console.error(`unknown mode: ${args[modeFlag + 1]} (tiers: ${MODES.join(", ")})`);
1224
+ process.exit(2);
1225
+ }
1226
+ setMode(m);
1227
+ args.splice(modeFlag, 2);
1228
+ }
1229
+ else {
1230
+ setMode(modeFromEnv());
1231
+ }
1232
+ const [command, arg] = args;
1154
1233
  // 八: faux mode is the keyless demo script — an exhausted script must
1155
1234
  // exit non-zero, never masquerade as a successful provider run.
1156
1235
  const faux = process.env.ANTHROPIC_API_KEY === undefined && process.env.OPENAI_API_KEY === undefined;
@@ -1160,6 +1239,15 @@ async function main() {
1160
1239
  // readline elsewhere. The trust question, chat, and resume all read
1161
1240
  // through it; main's finally closes it on every exit path.
1162
1241
  const input = makeLineInput();
1242
+ // v2d: the body renderer — active only where the dock is (a color
1243
+ // TTY with a real size); pipes run it in passthrough, byte-for-byte.
1244
+ body = new Body({
1245
+ active: () => process.stdin.isTTY && palette().blue !== "" && (process.stdout.rows ?? 0) >= 4,
1246
+ height: () => process.stdout.rows ?? 24,
1247
+ width: () => process.stdout.columns ?? 80,
1248
+ editCol: () => dock.editCol(),
1249
+ onDock: () => dock.redraw(), // v2d-B: the freeze scrolls the dock up — re-pin it
1250
+ });
1163
1251
  try {
1164
1252
  switch (command) {
1165
1253
  case "chat": {
@@ -1224,6 +1312,7 @@ async function main() {
1224
1312
  }
1225
1313
  }
1226
1314
  finally {
1315
+ body.close(); // flush the pending frame, stop the heartbeat
1227
1316
  input.close();
1228
1317
  // E 组: every normal and abnormal exit releases the fds and writer
1229
1318
  // locks — no lock file is left behind.
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;