@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/body.d.ts +4 -1
- package/dist/body.js +102 -32
- package/dist/diff.d.ts +32 -0
- package/dist/diff.js +122 -0
- package/dist/dock.d.ts +27 -28
- package/dist/dock.js +47 -54
- package/dist/editor.d.ts +11 -0
- package/dist/editor.js +70 -4
- package/dist/index.js +196 -57
- package/dist/mode.d.ts +33 -0
- package/dist/mode.js +93 -0
- package/dist/render.d.ts +30 -0
- package/dist/render.js +64 -4
- package/package.json +7 -7
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
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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(() =>
|
|
226
|
+
const timer = setInterval(() => onTick(GLYPHS[i++ % GLYPHS.length]), 200);
|
|
232
227
|
timer.unref();
|
|
233
|
-
return () =>
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
766
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
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
|
|
933
|
-
const
|
|
934
|
-
|
|
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
|
-
//
|
|
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
|
|
1071
|
-
|
|
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
|
-
|
|
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}${
|
|
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;
|