@vincemakes/kiso-code 0.1.15 → 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/body.d.ts +4 -1
- package/dist/body.js +87 -28
- package/dist/diff.d.ts +32 -0
- package/dist/diff.js +122 -0
- package/dist/dock.d.ts +6 -15
- package/dist/dock.js +6 -36
- package/dist/index.js +125 -22
- package/dist/mode.d.ts +33 -0
- package/dist/mode.js +93 -0
- package/dist/render.d.ts +1 -0
- package/dist/render.js +2 -2
- package/package.json +7 -7
package/dist/body.d.ts
CHANGED
|
@@ -39,6 +39,9 @@ export type BodyCell = {
|
|
|
39
39
|
state: "pending" | "approval" | "running" | "done";
|
|
40
40
|
isError: boolean;
|
|
41
41
|
resultText: string;
|
|
42
|
+
diff: import("./diff.js").DiffLine[] | null;
|
|
43
|
+
added: number;
|
|
44
|
+
removed: number;
|
|
42
45
|
startedAt: number | null;
|
|
43
46
|
doneAt: number | null;
|
|
44
47
|
done: boolean;
|
|
@@ -96,7 +99,7 @@ export declare class Body {
|
|
|
96
99
|
thinkingAppend(text: string): void;
|
|
97
100
|
thinkingEnd(): void;
|
|
98
101
|
toolStart(name: string, callId: string, input: Record<string, unknown>): void;
|
|
99
|
-
toolApproval(callId: string): void;
|
|
102
|
+
toolApproval(callId: string, diff: import("./diff.js").DiffResult | null): void;
|
|
100
103
|
toolRunning(callId: string): void;
|
|
101
104
|
toolSucceeded(callId: string): void;
|
|
102
105
|
toolFailed(callId: string, error: string): void;
|
package/dist/body.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* NoticeCell / raw block) — deliberately NOT pi's Component interface
|
|
24
24
|
* shape (ADR-0040).
|
|
25
25
|
*/
|
|
26
|
+
import { truncateDiff } from "./diff.js";
|
|
26
27
|
import { displayWidth } from "./editor.js";
|
|
27
28
|
import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
|
|
28
29
|
/** The spinner glyphs, cycled by the heartbeat. */
|
|
@@ -35,7 +36,7 @@ export class Body {
|
|
|
35
36
|
#opts;
|
|
36
37
|
#cells = [];
|
|
37
38
|
#nextFrozen = 0; // index of the first not-yet-printed cell
|
|
38
|
-
#frozenRows = 0; // rows
|
|
39
|
+
#frozenRows = 0; // the frozen area's rows filled WITHOUT scrolling (then the real LFs take over)
|
|
39
40
|
#oldTailTop = 0; // the tail's previous first row — for the clear pass
|
|
40
41
|
#frameTimer = null;
|
|
41
42
|
#heartbeat = null;
|
|
@@ -90,11 +91,13 @@ export class Body {
|
|
|
90
91
|
userLine(text) {
|
|
91
92
|
if (!this.#isActive()) {
|
|
92
93
|
this.#closeOpenThinking();
|
|
94
|
+
this.#closeOpenText();
|
|
93
95
|
const p = palette();
|
|
94
96
|
this.#write(`${p.blue}you> ${escapeTerminal(text)}${p.reset}\n`);
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
99
|
this.#closeOpenThinking();
|
|
100
|
+
this.#closeOpenText();
|
|
98
101
|
this.#cells.push({ kind: "user", text, done: true });
|
|
99
102
|
this.#mark();
|
|
100
103
|
}
|
|
@@ -129,19 +132,27 @@ export class Body {
|
|
|
129
132
|
this.#pendingCalls.set(callId, { name, input, result: { content: "", isError: false } });
|
|
130
133
|
if (!this.#isActive()) {
|
|
131
134
|
this.#closeOpenThinking();
|
|
135
|
+
this.#closeOpenText();
|
|
132
136
|
process.stdout.write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
|
|
133
137
|
return;
|
|
134
138
|
}
|
|
135
139
|
this.#toolCells.set(callId, this.#cells.length);
|
|
136
|
-
this.#cells.push({ kind: "tool", name, input: summary, state: "pending", isError: false, resultText: "", startedAt: null, doneAt: null, done: false });
|
|
140
|
+
this.#cells.push({ kind: "tool", name, input: summary, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false });
|
|
137
141
|
this.#mark();
|
|
138
142
|
}
|
|
139
|
-
toolApproval(callId) {
|
|
143
|
+
toolApproval(callId, diff) {
|
|
140
144
|
if (!this.#isActive())
|
|
141
145
|
return;
|
|
142
146
|
const cell = this.#toolCell(callId);
|
|
143
|
-
if (cell !== null && cell.kind === "tool" && !cell.done)
|
|
147
|
+
if (cell !== null && cell.kind === "tool" && !cell.done) {
|
|
144
148
|
cell.state = "approval";
|
|
149
|
+
// v2e: the mini-diff renders BELOW the tool line at the approval
|
|
150
|
+
// moment — the human sees the change before deciding. Auto-allowed
|
|
151
|
+
// tools pass null (nobody is looking — no diff, no cost).
|
|
152
|
+
cell.diff = diff === null ? null : truncateDiff(diff.lines);
|
|
153
|
+
cell.added = diff?.added ?? 0;
|
|
154
|
+
cell.removed = diff?.removed ?? 0;
|
|
155
|
+
}
|
|
145
156
|
this.#mark();
|
|
146
157
|
}
|
|
147
158
|
toolRunning(callId) {
|
|
@@ -196,6 +207,7 @@ export class Body {
|
|
|
196
207
|
textAppend(text) {
|
|
197
208
|
if (!this.#isActive()) {
|
|
198
209
|
this.#closeOpenThinking();
|
|
210
|
+
this.#closeOpenText();
|
|
199
211
|
process.stdout.write(escapeTerminal(text));
|
|
200
212
|
return;
|
|
201
213
|
}
|
|
@@ -205,6 +217,7 @@ export class Body {
|
|
|
205
217
|
}
|
|
206
218
|
else {
|
|
207
219
|
this.#closeOpenThinking();
|
|
220
|
+
this.#closeOpenText();
|
|
208
221
|
this.#cells.push({ kind: "text", text, done: false });
|
|
209
222
|
}
|
|
210
223
|
this.#mark();
|
|
@@ -223,21 +236,25 @@ export class Body {
|
|
|
223
236
|
terminal(label, statusLine) {
|
|
224
237
|
if (!this.#isActive()) {
|
|
225
238
|
this.#closeOpenThinking();
|
|
239
|
+
this.#closeOpenText();
|
|
226
240
|
// the v2c bytes: the terminal label (\ndone\n) + the status gap.
|
|
227
241
|
process.stdout.write(label + renderTerminalGap(statusLine));
|
|
228
242
|
return;
|
|
229
243
|
}
|
|
230
244
|
this.#closeOpenThinking();
|
|
245
|
+
this.#closeOpenText();
|
|
231
246
|
this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLine, done: true });
|
|
232
247
|
this.#mark();
|
|
233
248
|
}
|
|
234
249
|
notice(text) {
|
|
235
250
|
if (!this.#isActive()) {
|
|
236
251
|
this.#closeOpenThinking();
|
|
252
|
+
this.#closeOpenText();
|
|
237
253
|
process.stdout.write(`${text}\n`);
|
|
238
254
|
return;
|
|
239
255
|
}
|
|
240
256
|
this.#closeOpenThinking();
|
|
257
|
+
this.#closeOpenText();
|
|
241
258
|
this.#cells.push({ kind: "notice", text, done: true });
|
|
242
259
|
this.#mark();
|
|
243
260
|
}
|
|
@@ -246,11 +263,13 @@ export class Body {
|
|
|
246
263
|
raw(lines) {
|
|
247
264
|
if (!this.#isActive()) {
|
|
248
265
|
this.#closeOpenThinking();
|
|
266
|
+
this.#closeOpenText();
|
|
249
267
|
for (const line of lines)
|
|
250
268
|
process.stdout.write(`${line}\n`);
|
|
251
269
|
return;
|
|
252
270
|
}
|
|
253
271
|
this.#closeOpenThinking();
|
|
272
|
+
this.#closeOpenText();
|
|
254
273
|
this.#cells.push({ kind: "raw", lines, done: true });
|
|
255
274
|
this.#mark();
|
|
256
275
|
}
|
|
@@ -280,34 +299,47 @@ export class Body {
|
|
|
280
299
|
return;
|
|
281
300
|
const H = this.#opts.height();
|
|
282
301
|
const W = this.#opts.width();
|
|
283
|
-
|
|
284
|
-
if (regionBottom < 1)
|
|
302
|
+
if (H < 4)
|
|
285
303
|
return;
|
|
286
304
|
const out = [];
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
305
|
+
// #13 (P1), v2d-B: NO DECSTBM — overflow scrolls with a REAL LF at
|
|
306
|
+
// the screen's last row, so the frozen lines enter the terminal's
|
|
307
|
+
// NATIVE scrollback deterministically (region-scrolled lines are
|
|
308
|
+
// terminal-dependent; some terminals drop them — the measured v2d-A
|
|
309
|
+
// defect). The body fills from the top without scrolling; once full,
|
|
310
|
+
// every new frozen line scrolls the whole screen (\x1b[H;1H\n — the
|
|
311
|
+
// top line leaves into the scrollback) and lands at the body's
|
|
312
|
+
// bottom row, just above the active tail. The dock is redrawn after.
|
|
313
|
+
// The tail (the remaining ACTIVE cells) and its geometry — computed
|
|
314
|
+
// FIRST from the final nextFrozen, so the frozen cells are NOT in it
|
|
315
|
+
// (a stale tail would re-draw them — the double-render).
|
|
316
|
+
let nextFrozen = this.#nextFrozen;
|
|
317
|
+
while (nextFrozen < this.#cells.length && this.#cells[nextFrozen].done)
|
|
318
|
+
nextFrozen += 1;
|
|
319
|
+
const tail = this.#cells.slice(nextFrozen);
|
|
320
|
+
const tailHeight = tail.reduce((n, c) => n + this.#cellHeight(c, W), 0);
|
|
321
|
+
const tailTop = Math.max(1, H - 2 - tailHeight);
|
|
322
|
+
const writeRow = Math.max(1, tailTop - 1); // the frozen area's bottom row
|
|
323
|
+
let scrolled = 0;
|
|
324
|
+
for (let i = this.#nextFrozen; i < nextFrozen; i += 1) {
|
|
325
|
+
for (const line of this.#cellLines(this.#cells[i], W)) {
|
|
326
|
+
if (this.#frozenRows < writeRow) {
|
|
327
|
+
this.#frozenRows += 1;
|
|
328
|
+
out.push(`\x1b[${this.#frozenRows};1H\x1b[0K${line}`);
|
|
298
329
|
}
|
|
299
330
|
else {
|
|
300
|
-
|
|
331
|
+
out.push(`\x1b[${H};1H\n`); // the REAL LF — the whole screen scrolls
|
|
332
|
+
out.push(`\x1b[${writeRow};1H\x1b[0K${line}`);
|
|
333
|
+
scrolled += 1;
|
|
301
334
|
}
|
|
302
335
|
}
|
|
303
336
|
this.#nextFrozen += 1;
|
|
304
337
|
}
|
|
305
|
-
// 2. the active tail — clear its old area
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
for (let row = clearFrom; row <= regionBottom; row += 1) {
|
|
338
|
+
// 2. the active tail — clear its old area (shifted up by the freeze
|
|
339
|
+
// scrolls) and the current area, draw the cells at the body's bottom.
|
|
340
|
+
out.push("\x1b[?2026h");
|
|
341
|
+
const clearFrom = Math.min(this.#oldTailTop === 0 ? tailTop : this.#oldTailTop - scrolled, tailTop);
|
|
342
|
+
for (let row = clearFrom; row <= H - 3; row += 1) {
|
|
311
343
|
out.push(`\x1b[${row};1H\x1b[0K`);
|
|
312
344
|
}
|
|
313
345
|
let row = tailTop;
|
|
@@ -317,11 +349,13 @@ export class Body {
|
|
|
317
349
|
row += 1;
|
|
318
350
|
}
|
|
319
351
|
}
|
|
320
|
-
this.#oldTailTop = tailTop;
|
|
321
352
|
// 3. the cursor home — the input line's edit column.
|
|
322
353
|
out.push(`\x1b[${H};${this.#opts.editCol()}H`);
|
|
323
354
|
out.push("\x1b[?2026l");
|
|
324
355
|
this.#write(out.join(""));
|
|
356
|
+
// 4. the dock rows — the freeze scrolls shifted them; redraw (the
|
|
357
|
+
// dock's own redraw re-pins the cursor at the edit position).
|
|
358
|
+
this.#opts.onDock?.();
|
|
325
359
|
}
|
|
326
360
|
// ---- cell → lines ----
|
|
327
361
|
#cellLines(cell, W) {
|
|
@@ -345,10 +379,25 @@ export class Body {
|
|
|
345
379
|
const err = escapeTerminal(cell.resultText.split("\n")[0].slice(0, 60));
|
|
346
380
|
return [`${p.red}✗ ${name} (${err}, ${elapsed}s)${p.reset}`];
|
|
347
381
|
}
|
|
348
|
-
|
|
382
|
+
const delta = cell.added + cell.removed > 0 ? `, +${cell.added} -${cell.removed}` : "";
|
|
383
|
+
return [`${p.blue}✓ ${name}${p.reset} (${summary}${delta}, ${elapsed}s)`];
|
|
384
|
+
}
|
|
385
|
+
if (cell.state === "approval") {
|
|
386
|
+
const lines = [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
|
|
387
|
+
// v2e: the mini-diff — ▎ blue edge (the brick motif), - red /
|
|
388
|
+
// + green / context dim; NO_COLOR keeps the ± prefixes plain.
|
|
389
|
+
if (cell.diff !== null) {
|
|
390
|
+
for (const d of cell.diff) {
|
|
391
|
+
const body = d.kind === "-"
|
|
392
|
+
? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
|
|
393
|
+
: d.kind === "+"
|
|
394
|
+
? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
|
|
395
|
+
: `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
|
|
396
|
+
lines.push(`${p.blue}▎${p.reset}${body}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return lines;
|
|
349
400
|
}
|
|
350
|
-
if (cell.state === "approval")
|
|
351
|
-
return [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
|
|
352
401
|
if (cell.state === "running") {
|
|
353
402
|
const elapsed = cell.startedAt !== null ? Math.max(1, Math.round((Date.now() - cell.startedAt) / 1000)) : 1;
|
|
354
403
|
return [`→ ${name} ${summary} ${p.blue}${SPINNER[this.#spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`];
|
|
@@ -401,6 +450,16 @@ export class Body {
|
|
|
401
450
|
const i = this.#toolCells.get(callId);
|
|
402
451
|
return i === undefined ? null : (this.#cells[i] ?? null);
|
|
403
452
|
}
|
|
453
|
+
/** Close an open TEXT cell when a new cell starts — the runtime emits
|
|
454
|
+
* no text_end (it is an adapter-level event), so the stream's next
|
|
455
|
+
* cell is the close signal; without it the freeze blocks behind the
|
|
456
|
+
* open text and everything after it re-renders in the tail forever
|
|
457
|
+
* (the #13 flood reproduced the overwrite). */
|
|
458
|
+
#closeOpenText() {
|
|
459
|
+
const last = this.#cells[this.#cells.length - 1];
|
|
460
|
+
if (last !== undefined && last.kind === "text" && !last.done)
|
|
461
|
+
last.done = true;
|
|
462
|
+
}
|
|
404
463
|
/** Close an open thinking cell when a new cell starts (the block's
|
|
405
464
|
* fold freezes at the transition). */
|
|
406
465
|
#closeOpenThinking() {
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2e — the diff renderer: edit/write changes as inline ± lines, zero
|
|
3
|
+
* dependencies, no syntax highlighting (the spec's scope line). Shown at
|
|
4
|
+
* the approval moment ONLY — the frozen summary stays one line (v2d's
|
|
5
|
+
* anti-leak principle), /last has the full data.
|
|
6
|
+
*
|
|
7
|
+
* edit_file diffs IN PLACE (the search→replace windows are known — no
|
|
8
|
+
* general engine needed); write_file does a row-level LCS over the old
|
|
9
|
+
* file (small files are the target). Context: 2 rows each side. The
|
|
10
|
+
* RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
|
|
11
|
+
* from the full diff.
|
|
12
|
+
*/
|
|
13
|
+
/** The diff block's per-row kind. */
|
|
14
|
+
export type DiffLine = {
|
|
15
|
+
kind: "-" | "+" | " ";
|
|
16
|
+
text: string;
|
|
17
|
+
};
|
|
18
|
+
export interface DiffResult {
|
|
19
|
+
/** The FULL diff (with context, not truncated) — the display truncates. */
|
|
20
|
+
lines: DiffLine[];
|
|
21
|
+
added: number;
|
|
22
|
+
removed: number;
|
|
23
|
+
}
|
|
24
|
+
/** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
|
|
25
|
+
export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
|
|
26
|
+
/** edit_file: the search→replace windows replace in place — the changed
|
|
27
|
+
* region is KNOWN, so the diff is the old window vs the new window,
|
|
28
|
+
* context from the surrounding file. */
|
|
29
|
+
export declare function editFileDiff(oldContent: string, search: string, replace: string): DiffResult;
|
|
30
|
+
/** write_file: a new file is all +; an existing file diffs row-level
|
|
31
|
+
* against its old content. */
|
|
32
|
+
export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2e — the diff renderer: edit/write changes as inline ± lines, zero
|
|
3
|
+
* dependencies, no syntax highlighting (the spec's scope line). Shown at
|
|
4
|
+
* the approval moment ONLY — the frozen summary stays one line (v2d's
|
|
5
|
+
* anti-leak principle), /last has the full data.
|
|
6
|
+
*
|
|
7
|
+
* edit_file diffs IN PLACE (the search→replace windows are known — no
|
|
8
|
+
* general engine needed); write_file does a row-level LCS over the old
|
|
9
|
+
* file (small files are the target). Context: 2 rows each side. The
|
|
10
|
+
* RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
|
|
11
|
+
* from the full diff.
|
|
12
|
+
*/
|
|
13
|
+
/** A line-level LCS diff — the classic two-row DP, ~small inputs. */
|
|
14
|
+
function lcsDiff(oldLines, newLines) {
|
|
15
|
+
const n = oldLines.length;
|
|
16
|
+
const m = newLines.length;
|
|
17
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
18
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
19
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
20
|
+
dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const out = [];
|
|
24
|
+
let i = 0;
|
|
25
|
+
let j = 0;
|
|
26
|
+
while (i < n && j < m) {
|
|
27
|
+
if (oldLines[i] === newLines[j]) {
|
|
28
|
+
out.push({ kind: " ", text: oldLines[i] });
|
|
29
|
+
i += 1;
|
|
30
|
+
j += 1;
|
|
31
|
+
}
|
|
32
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
33
|
+
out.push({ kind: "-", text: oldLines[i] });
|
|
34
|
+
i += 1;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
out.push({ kind: "+", text: newLines[j] });
|
|
38
|
+
j += 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
while (i < n) {
|
|
42
|
+
out.push({ kind: "-", text: oldLines[i] });
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
while (j < m) {
|
|
46
|
+
out.push({ kind: "+", text: newLines[j] });
|
|
47
|
+
j += 1;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** Keep 2 context rows around each change — the unified-style window. */
|
|
52
|
+
function withContext(diff) {
|
|
53
|
+
const out = [];
|
|
54
|
+
let lastAdded = -10;
|
|
55
|
+
for (let k = 0; k < diff.length; k += 1) {
|
|
56
|
+
if (diff[k].kind === " ")
|
|
57
|
+
continue;
|
|
58
|
+
const from = Math.max(0, k - 2);
|
|
59
|
+
const to = Math.min(diff.length - 1, k + 2);
|
|
60
|
+
for (let c = from; c <= to; c += 1) {
|
|
61
|
+
if (c > lastAdded) {
|
|
62
|
+
out.push(diff[c]);
|
|
63
|
+
lastAdded = c;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
lastAdded = to;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
const MAX_DIFF_LINES = 40; // the RENDERED cap
|
|
71
|
+
const TRUNCATE_KEEP = 18;
|
|
72
|
+
/** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
|
|
73
|
+
export function truncateDiff(diff) {
|
|
74
|
+
if (diff.length <= MAX_DIFF_LINES)
|
|
75
|
+
return diff;
|
|
76
|
+
const omitted = diff.length - 2 * TRUNCATE_KEEP;
|
|
77
|
+
return [
|
|
78
|
+
...diff.slice(0, TRUNCATE_KEEP),
|
|
79
|
+
{ kind: " ", text: `… ${omitted} lines (/last for full)` },
|
|
80
|
+
...diff.slice(diff.length - TRUNCATE_KEEP),
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
function stats(diff) {
|
|
84
|
+
let added = 0;
|
|
85
|
+
let removed = 0;
|
|
86
|
+
for (const d of diff) {
|
|
87
|
+
if (d.kind === "+")
|
|
88
|
+
added += 1;
|
|
89
|
+
else if (d.kind === "-")
|
|
90
|
+
removed += 1;
|
|
91
|
+
}
|
|
92
|
+
return { added, removed };
|
|
93
|
+
}
|
|
94
|
+
/** edit_file: the search→replace windows replace in place — the changed
|
|
95
|
+
* region is KNOWN, so the diff is the old window vs the new window,
|
|
96
|
+
* context from the surrounding file. */
|
|
97
|
+
export function editFileDiff(oldContent, search, replace) {
|
|
98
|
+
const oldLines = oldContent.split("\n");
|
|
99
|
+
const searchLines = search.split("\n");
|
|
100
|
+
const replaceLines = replace.split("\n");
|
|
101
|
+
// Locate the search window (the first occurrence — the edit tool's own
|
|
102
|
+
// semantics); no occurrence → the whole file is the old side.
|
|
103
|
+
let at = -1;
|
|
104
|
+
for (let i = 0; i + searchLines.length <= oldLines.length; i += 1) {
|
|
105
|
+
if (oldLines.slice(i, i + searchLines.length).join("\n") === search) {
|
|
106
|
+
at = i;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const lines = at < 0 ? withContext(lcsDiff(oldLines, replaceLines)) : withContext(lcsDiff(oldLines, [...oldLines.slice(0, at), ...replaceLines, ...oldLines.slice(at + searchLines.length)]));
|
|
111
|
+
return { lines, ...stats(lines) };
|
|
112
|
+
}
|
|
113
|
+
/** write_file: a new file is all +; an existing file diffs row-level
|
|
114
|
+
* against its old content. */
|
|
115
|
+
export function writeFileDiff(oldContent, newContent) {
|
|
116
|
+
if (oldContent === null) {
|
|
117
|
+
const lines = newContent.split("\n").map((text) => ({ kind: "+", text }));
|
|
118
|
+
return { lines, added: lines.length, removed: 0 };
|
|
119
|
+
}
|
|
120
|
+
const lines = withContext(lcsDiff(oldContent.split("\n"), newContent.split("\n")));
|
|
121
|
+
return { lines, ...stats(lines) };
|
|
122
|
+
}
|
package/dist/dock.d.ts
CHANGED
|
@@ -27,7 +27,11 @@ export declare class Dock {
|
|
|
27
27
|
line: string;
|
|
28
28
|
cursor: number;
|
|
29
29
|
}, prompt: string): void;
|
|
30
|
-
/** Enter docked mode:
|
|
30
|
+
/** Enter docked mode: draw the chrome. #13 (P1): the DECSTBM scroll
|
|
31
|
+
* region is GONE — v2d-B (ADR-0040): the body uses plain LF scrolling
|
|
32
|
+
* so frozen lines enter the native scrollback deterministically
|
|
33
|
+
* (region-scrolled lines are terminal-dependent — some terminals drop
|
|
34
|
+
* them). The dock rows are redrawn by the body after every scroll. A
|
|
31
35
|
* TTY without a real window size (rows < 4) stays in the v2a line
|
|
32
36
|
* mode — the bottom three rows need room to exist. */
|
|
33
37
|
enter(): void;
|
|
@@ -36,21 +40,8 @@ export declare class Dock {
|
|
|
36
40
|
* from main's finally on EVERY exit path (kill -9 excepted — README:
|
|
37
41
|
* `reset` saves it). */
|
|
38
42
|
exit(): void;
|
|
39
|
-
/** SIGWINCH: recompute the
|
|
43
|
+
/** SIGWINCH: recompute the size, redraw the chrome. */
|
|
40
44
|
onResize(): void;
|
|
41
|
-
/** Body output: position the cursor inside the scroll region at the
|
|
42
|
-
* body's tracked position, write, and hand the cursor back to the
|
|
43
|
-
* input line's EDIT position. The row/col tracking is approximate for
|
|
44
|
-
* width-wrapped and wide-char lines (documented) — the region clamp
|
|
45
|
-
* keeps the bottom rows safe regardless.
|
|
46
|
-
*
|
|
47
|
-
* The edit-position return is a correctness requirement, not
|
|
48
|
-
* cosmetics: readline tracks its cursor internally and NEVER
|
|
49
|
-
* re-syncs after an external move — a body write that left the
|
|
50
|
-
* cursor at column 1 made the next keystroke overwrite the prompt
|
|
51
|
-
* (probe-confirmed; the dock's redraw self-repaired ~200ms later,
|
|
52
|
-
* which read the user as cursor drift). */
|
|
53
|
-
writeBody(text: string): void;
|
|
54
45
|
/** The input line's edit column — prompt width + cursor + 1. The
|
|
55
46
|
* dock's redraw and the body's cursor return both end here, so the
|
|
56
47
|
* ACTUAL cursor always equals what the editor tracks. The width is
|
package/dist/dock.js
CHANGED
|
@@ -41,7 +41,11 @@ export class Dock {
|
|
|
41
41
|
this.#inputState = state;
|
|
42
42
|
this.#inputPrompt = prompt;
|
|
43
43
|
}
|
|
44
|
-
/** Enter docked mode:
|
|
44
|
+
/** Enter docked mode: draw the chrome. #13 (P1): the DECSTBM scroll
|
|
45
|
+
* region is GONE — v2d-B (ADR-0040): the body uses plain LF scrolling
|
|
46
|
+
* so frozen lines enter the native scrollback deterministically
|
|
47
|
+
* (region-scrolled lines are terminal-dependent — some terminals drop
|
|
48
|
+
* them). The dock rows are redrawn by the body after every scroll. A
|
|
45
49
|
* TTY without a real window size (rows < 4) stays in the v2a line
|
|
46
50
|
* mode — the bottom three rows need room to exist. */
|
|
47
51
|
enter() {
|
|
@@ -53,7 +57,6 @@ export class Dock {
|
|
|
53
57
|
this.#width = process.stdout.columns ?? 80;
|
|
54
58
|
this.#bodyRow = 1;
|
|
55
59
|
this.#bodyCol = 1;
|
|
56
|
-
process.stdout.write(`\x1b[1;${this.#height - 3}r`); // scroll region: top .. H-3
|
|
57
60
|
this.redraw();
|
|
58
61
|
this.#resizeHandler = () => this.onResize();
|
|
59
62
|
process.stdout.on("resize", this.#resizeHandler);
|
|
@@ -77,47 +80,14 @@ export class Dock {
|
|
|
77
80
|
}
|
|
78
81
|
process.stdout.write(`\x1b[${H};1H`);
|
|
79
82
|
}
|
|
80
|
-
/** SIGWINCH: recompute the
|
|
83
|
+
/** SIGWINCH: recompute the size, redraw the chrome. */
|
|
81
84
|
onResize() {
|
|
82
85
|
if (!this.#active)
|
|
83
86
|
return;
|
|
84
87
|
this.#height = process.stdout.rows ?? this.#height;
|
|
85
88
|
this.#width = process.stdout.columns ?? this.#width;
|
|
86
|
-
process.stdout.write(`\x1b[1;${this.#height - 3}r`);
|
|
87
89
|
this.redraw();
|
|
88
90
|
}
|
|
89
|
-
/** Body output: position the cursor inside the scroll region at the
|
|
90
|
-
* body's tracked position, write, and hand the cursor back to the
|
|
91
|
-
* input line's EDIT position. The row/col tracking is approximate for
|
|
92
|
-
* width-wrapped and wide-char lines (documented) — the region clamp
|
|
93
|
-
* keeps the bottom rows safe regardless.
|
|
94
|
-
*
|
|
95
|
-
* The edit-position return is a correctness requirement, not
|
|
96
|
-
* cosmetics: readline tracks its cursor internally and NEVER
|
|
97
|
-
* re-syncs after an external move — a body write that left the
|
|
98
|
-
* cursor at column 1 made the next keystroke overwrite the prompt
|
|
99
|
-
* (probe-confirmed; the dock's redraw self-repaired ~200ms later,
|
|
100
|
-
* which read the user as cursor drift). */
|
|
101
|
-
writeBody(text) {
|
|
102
|
-
if (!this.#active) {
|
|
103
|
-
process.stdout.write(text);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const row = Math.min(this.#bodyRow, this.#height - 3);
|
|
107
|
-
const col = this.#bodyCol > this.#width ? this.#width : this.#bodyCol;
|
|
108
|
-
process.stdout.write(`\x1b[${row};${col}H`);
|
|
109
|
-
process.stdout.write(text);
|
|
110
|
-
for (const ch of text) {
|
|
111
|
-
if (ch === "\n") {
|
|
112
|
-
this.#bodyRow += 1;
|
|
113
|
-
this.#bodyCol = 1;
|
|
114
|
-
}
|
|
115
|
-
else {
|
|
116
|
-
this.#bodyCol += 1;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
process.stdout.write(`\x1b[${this.#height};${this.#inputCol()}H`); // back to the edit position
|
|
120
|
-
}
|
|
121
91
|
/** The input line's edit column — prompt width + cursor + 1. The
|
|
122
92
|
* dock's redraw and the body's cursor return both end here, so the
|
|
123
93
|
* ACTUAL cursor always equals what the editor tracks. The width is
|
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";
|
|
@@ -27,17 +29,6 @@ import { createFauxProvider } from "@vincemakes/kiso-evals";
|
|
|
27
29
|
import { createCodingTools } from "@vincemakes/kiso-tools-node";
|
|
28
30
|
import { escapeTerminal, foldResult, foldThinking, palette, renderEvent, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, } 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)
|
|
@@ -260,6 +251,21 @@ function bannerExtensionText() {
|
|
|
260
251
|
parts.push(`project: ${projectExtensions.map((e) => e.name).join(", ")}`);
|
|
261
252
|
return ` · [${total} extension${total === 1 ? "" : "s"}: ${parts.join(" · ")}]`;
|
|
262
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
|
+
}
|
|
263
269
|
/** E1: the startup banner line(s) — TTY: logo + merged extensions; off-TTY:
|
|
264
270
|
* the historical `[N extensions: ...]` standalone line (zero change). */
|
|
265
271
|
function extensionsBanner() {
|
|
@@ -518,15 +524,26 @@ async function makeAgent(fauxSkipTurns = 0, input) {
|
|
|
518
524
|
// Area 5: the coding tools are bound to the workspace — every path
|
|
519
525
|
// they touch is canonicalized inside cwd, escapes are refused.
|
|
520
526
|
tools: [...createCodingTools({ workspaceRoot: process.cwd() })],
|
|
521
|
-
|
|
522
|
-
|
|
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
|
+
})(),
|
|
523
537
|
// C 区: microcompact is ON by default in the product — threshold =
|
|
524
538
|
// half the model window (KISO_CONTEXT_WINDOW override included;
|
|
525
539
|
// 200k window → 100k tokens). Long sessions compact old read/list/
|
|
526
540
|
// search/shell outputs instead of silently growing past the window.
|
|
527
541
|
microcompact: { thresholdTokens: contextWindowTokens() / 2 },
|
|
528
542
|
maxTurns: 20,
|
|
529
|
-
|
|
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],
|
|
530
547
|
...(provider !== undefined
|
|
531
548
|
? {
|
|
532
549
|
provider,
|
|
@@ -699,6 +716,38 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
|
699
716
|
* line's form; `liveInput` (non-null only in interactive chat) carries the
|
|
700
717
|
* last line THIS process's readline consumed — the double-echo filter.
|
|
701
718
|
*/
|
|
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
|
+
}
|
|
702
751
|
async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
|
|
703
752
|
let last;
|
|
704
753
|
let usage = { in: null, out: null, cache: null, known: false };
|
|
@@ -762,9 +811,12 @@ async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb
|
|
|
762
811
|
case "permission_requested": {
|
|
763
812
|
// v2d: the ToolCell shows the ⏸ badge; the question takes over
|
|
764
813
|
// the dock status position; the answer lands at the input line.
|
|
765
|
-
|
|
766
|
-
|
|
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).
|
|
767
817
|
const name = ev.name;
|
|
818
|
+
body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
|
|
819
|
+
const decisionId = ev.decisionId;
|
|
768
820
|
const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
|
|
769
821
|
if (answer === CANCELLED) {
|
|
770
822
|
// 十: a cancellation is a CONSERVATIVE denial, explicitly
|
|
@@ -777,6 +829,7 @@ async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb
|
|
|
777
829
|
break;
|
|
778
830
|
}
|
|
779
831
|
case "terminal":
|
|
832
|
+
statusCb?.(usage, estimateCtxRatio(session));
|
|
780
833
|
statusCb?.(usage, estimateCtxRatio(session));
|
|
781
834
|
// v2a rhythm: the honest label (\ndone\n — the completed
|
|
782
835
|
// marker), the status line hugging it, then EXACTLY one blank
|
|
@@ -925,13 +978,20 @@ async function chat(session, faux, input) {
|
|
|
925
978
|
// v2c: turns submitted while another runs are QUEUED on the chain — the
|
|
926
979
|
// live count rides the status bar (+N queued).
|
|
927
980
|
let queued = 0;
|
|
928
|
-
// 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;
|
|
929
984
|
const statusCb = (u, ctx) => {
|
|
930
985
|
if (!dock.active)
|
|
931
986
|
return;
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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` : ""}`);
|
|
935
995
|
};
|
|
936
996
|
// The ONE dispatcher: slash commands, exit, and turns. The recovery
|
|
937
997
|
// replay routes through it too — a queued "/last" must never become a
|
|
@@ -949,6 +1009,7 @@ async function chat(session, faux, input) {
|
|
|
949
1009
|
bodyLog(cmd("/think", "show the last full thinking block"));
|
|
950
1010
|
bodyLog(cmd("/last", "show the most recent tool call's input and output"));
|
|
951
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)"));
|
|
952
1013
|
bodyLog(cmd("exit", "leave the session"));
|
|
953
1014
|
input.prompt();
|
|
954
1015
|
});
|
|
@@ -1002,6 +1063,29 @@ async function chat(session, faux, input) {
|
|
|
1002
1063
|
});
|
|
1003
1064
|
return;
|
|
1004
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
|
+
}
|
|
1005
1089
|
if (trimmed === "exit" || trimmed === "") {
|
|
1006
1090
|
input.close();
|
|
1007
1091
|
return;
|
|
@@ -1068,7 +1152,8 @@ async function resume(session, prompt, faux, input) {
|
|
|
1068
1152
|
if (!dock.active)
|
|
1069
1153
|
return;
|
|
1070
1154
|
const st = renderStatusLine(turnNo, u, ctx, faux);
|
|
1071
|
-
|
|
1155
|
+
const base = st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`;
|
|
1156
|
+
dock.setStatus(statusWithMode(base));
|
|
1072
1157
|
};
|
|
1073
1158
|
const withRun = async (run) => {
|
|
1074
1159
|
currentRun = run;
|
|
@@ -1127,7 +1212,24 @@ async function resume(session, prompt, faux, input) {
|
|
|
1127
1212
|
}
|
|
1128
1213
|
}
|
|
1129
1214
|
async function main() {
|
|
1130
|
-
|
|
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;
|
|
1131
1233
|
// 八: faux mode is the keyless demo script — an exhausted script must
|
|
1132
1234
|
// exit non-zero, never masquerade as a successful provider run.
|
|
1133
1235
|
const faux = process.env.ANTHROPIC_API_KEY === undefined && process.env.OPENAI_API_KEY === undefined;
|
|
@@ -1144,6 +1246,7 @@ async function main() {
|
|
|
1144
1246
|
height: () => process.stdout.rows ?? 24,
|
|
1145
1247
|
width: () => process.stdout.columns ?? 80,
|
|
1146
1248
|
editCol: () => dock.editCol(),
|
|
1249
|
+
onDock: () => dock.redraw(), // v2d-B: the freeze scrolls the dock up — re-pin it
|
|
1147
1250
|
});
|
|
1148
1251
|
try {
|
|
1149
1252
|
switch (command) {
|
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
package/dist/render.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* the lines a human sees. Colors are raw ANSI — no dependencies.
|
|
4
4
|
*/
|
|
5
5
|
import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
|
|
6
|
-
export const COLOR_ON = { blue: "\x1b[38;5;75m", dim: "\x1b[2m", red: "\x1b[31m", reset: "\x1b[0m" };
|
|
7
|
-
export const COLOR_OFF = { blue: "", dim: "", red: "", reset: "" };
|
|
6
|
+
export const COLOR_ON = { blue: "\x1b[38;5;75m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", reset: "\x1b[0m" };
|
|
7
|
+
export const COLOR_OFF = { blue: "", dim: "", red: "", green: "", reset: "" };
|
|
8
8
|
export function palette() {
|
|
9
9
|
return process.env.NO_COLOR === undefined && process.stdout.isTTY ? COLOR_ON : COLOR_OFF;
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-code",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "kiso CLI — the coding-agent reference product: kiso chat / kiso resume / kiso sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
"test": "vitest run"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@vincemakes/kiso-core": "0.1.
|
|
22
|
-
"@vincemakes/kiso-evals": "0.1.
|
|
23
|
-
"@vincemakes/kiso-provider-anthropic": "0.1.
|
|
24
|
-
"@vincemakes/kiso-provider-openai": "0.1.
|
|
25
|
-
"@vincemakes/kiso-runtime": "0.1.
|
|
26
|
-
"@vincemakes/kiso-tools-node": "0.1.
|
|
21
|
+
"@vincemakes/kiso-core": "0.1.16",
|
|
22
|
+
"@vincemakes/kiso-evals": "0.1.16",
|
|
23
|
+
"@vincemakes/kiso-provider-anthropic": "0.1.16",
|
|
24
|
+
"@vincemakes/kiso-provider-openai": "0.1.16",
|
|
25
|
+
"@vincemakes/kiso-runtime": "0.1.16",
|
|
26
|
+
"@vincemakes/kiso-tools-node": "0.1.16"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^26.1.2",
|