@vincemakes/kiso-code 0.31.1 → 0.32.1
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/breaker.d.ts +57 -0
- package/dist/breaker.js +119 -0
- package/dist/chat.d.ts +21 -0
- package/dist/chat.js +74 -3
- package/dist/config.d.ts +3 -0
- package/dist/config.js +3 -0
- package/dist/dispatch.d.ts +24 -4
- package/dist/dispatch.js +195 -2
- package/dist/external-editor.d.ts +30 -0
- package/dist/external-editor.js +55 -0
- package/dist/index.js +190 -7
- package/dist/state.d.ts +35 -0
- package/dist/state.js +27 -0
- package/dist/trust-ui.js +111 -18
- package/package.json +16 -16
package/dist/dispatch.js
CHANGED
|
@@ -8,8 +8,10 @@ import { newSessionId } from "./session-id.js";
|
|
|
8
8
|
import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
|
|
9
9
|
import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
|
|
10
10
|
import { clipboardWrite, lastAnswer } from "./clipboard.js";
|
|
11
|
-
import { agentModel, body, bodyLog, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
|
|
11
|
+
import { agentModel, body, bodyLog, codingToolOptions, kisoHome, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setModelChoice, setLastBinding } from "./state.js";
|
|
12
12
|
import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
|
|
13
|
+
import { shellTool } from "@vincemakes/kiso-tools-node";
|
|
14
|
+
import { join } from "node:path";
|
|
13
15
|
/** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
|
|
14
16
|
* and its default, from the registry, shown wherever the profile is listed
|
|
15
17
|
* — kiso never silently drops a level, and now it shows the ones it takes.
|
|
@@ -77,8 +79,98 @@ import { oauthTokenThunk } from "./auth/token.js";
|
|
|
77
79
|
* replay routes through it too — a queued "/last" must never become a
|
|
78
80
|
* user turn (v2c: the rl lives in main, so lines arrive earlier and the
|
|
79
81
|
* queue is the common path). */
|
|
82
|
+
/** §2.2 — the `!` command in flight, if one is. A `!` command and a model
|
|
83
|
+
* run are never both running (the dispatcher is serialized on the chain),
|
|
84
|
+
* so one slot is the whole state. */
|
|
85
|
+
let activeBang = null;
|
|
86
|
+
/** esc while a `!` command runs kills it — the SAME abort the shell tool
|
|
87
|
+
* gives a model's call, so the whole process group goes, children
|
|
88
|
+
* included. Exported because the esc key reaches the editor first: chat
|
|
89
|
+
* offers it here before it reaches a model run, and only one of the two
|
|
90
|
+
* can be in flight. */
|
|
91
|
+
export function abortBangCommand() {
|
|
92
|
+
if (activeBang === null)
|
|
93
|
+
return false;
|
|
94
|
+
activeBang.abort();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/** §2.2 — what a submitted line asks for, if it asks for a shell at all.
|
|
98
|
+
* Split out from the dispatcher so the rule is testable without a
|
|
99
|
+
* terminal: `null` means this is not the gesture and the line is a turn.
|
|
100
|
+
*
|
|
101
|
+
* `!!` is checked before `!` because the longer prefix is the narrower
|
|
102
|
+
* claim; testing `!` first would swallow both. */
|
|
103
|
+
export function parseBang(trimmed) {
|
|
104
|
+
// a multi-line paste that merely begins with `!` is prose, the same
|
|
105
|
+
// rule `/` already follows one branch below.
|
|
106
|
+
if (trimmed.includes("\n") || !trimmed.startsWith("!"))
|
|
107
|
+
return null;
|
|
108
|
+
const send = !trimmed.startsWith("!!");
|
|
109
|
+
return { command: trimmed.slice(send ? 1 : 2).trim(), send };
|
|
110
|
+
}
|
|
111
|
+
/** §2.2 — `!cmd` runs a command and SENDS it, `!!cmd` runs one and shows
|
|
112
|
+
* it. Both go through the shell tool's own runner, so the timeout (30 s),
|
|
113
|
+
* the output cap (100,000 chars, which names what it dropped), the
|
|
114
|
+
* whole-tree kill and the STRIPPED environment are the tool's and not a
|
|
115
|
+
* second set of rules. The `!`/`!!` difference is what the MODEL sees,
|
|
116
|
+
* never what leaves the machine.
|
|
117
|
+
*
|
|
118
|
+
* No approval panel, at any tier including plan: the human typed the
|
|
119
|
+
* command. That is not a policy exemption, it is structural — this
|
|
120
|
+
* dispatcher only ever sees lines a human submitted, so a model cannot
|
|
121
|
+
* reach this branch. */
|
|
122
|
+
function runBang(command, send, ctx) {
|
|
123
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
activeBang = controller;
|
|
126
|
+
try {
|
|
127
|
+
const tool = shellTool(codingToolOptions());
|
|
128
|
+
const result = await tool.execute({ command }, { signal: controller.signal });
|
|
129
|
+
// one fenced block, read as a terminal transcript: the command
|
|
130
|
+
// and what it printed. The tool's own overflow note rides inside
|
|
131
|
+
// `content`, so a capped run says so in the same words a model's
|
|
132
|
+
// call would.
|
|
133
|
+
const block = ["```console", `$ ${command}`, result.content, "```"].join("\n");
|
|
134
|
+
if (send) {
|
|
135
|
+
// an ORDINARY user turn: durable, in the log, and the model
|
|
136
|
+
// sees exactly what the human saw.
|
|
137
|
+
ctx.submitTurn(block);
|
|
138
|
+
return; // submitTurn owns the prompt from here
|
|
139
|
+
}
|
|
140
|
+
bodyLog(block, "words");
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
activeBang = null;
|
|
144
|
+
}
|
|
145
|
+
ctx.input.prompt();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
80
148
|
export function dispatch(line, ctx) {
|
|
81
149
|
const trimmed = line.trim();
|
|
150
|
+
// §2.2 — the `!` gesture is the COMPOSER's alone. A piped session, `-p`
|
|
151
|
+
// and `--task-file` keep `!` as ordinary text: those lines are content,
|
|
152
|
+
// and a headless run that shelled out because a prompt began with `!`
|
|
153
|
+
// would be executing its input.
|
|
154
|
+
if (dock.active && !trimmed.includes("\n")) {
|
|
155
|
+
if (trimmed.startsWith("\\!")) {
|
|
156
|
+
// the ONE escape: `\!` sends the literal text starting with `!`.
|
|
157
|
+
ctx.submitTurn(line.replace("\\!", "!"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const bang = parseBang(trimmed);
|
|
161
|
+
if (bang !== null) {
|
|
162
|
+
const { command, send } = bang;
|
|
163
|
+
if (command === "") {
|
|
164
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
165
|
+
bodyLog("!<command> runs it and sends the result · !!<command> runs it and shows it here · \\! sends a literal !");
|
|
166
|
+
ctx.input.prompt();
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
runBang(command, send, ctx);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
82
174
|
if (trimmed === "/help") {
|
|
83
175
|
// KC3.5 slice ⓪ (the extraction): the ROWS moved to the terminal
|
|
84
176
|
// layer's strings module (helpRows — the KC3 §1 pattern: what the
|
|
@@ -170,6 +262,75 @@ export function dispatch(line, ctx) {
|
|
|
170
262
|
ctx.input.prompt();
|
|
171
263
|
return;
|
|
172
264
|
}
|
|
265
|
+
if (trimmed === "\x07editor") {
|
|
266
|
+
// §2.4 — ctrl+g hands the composer to the human's own editor.
|
|
267
|
+
//
|
|
268
|
+
// The split: the EDITOR owns the terminal handover (raw mode, the
|
|
269
|
+
// mouse, bracketed paste — see `externalEdit`), and this owns the
|
|
270
|
+
// spawn, because the tui package is pure terminal and spawning is
|
|
271
|
+
// not. Neither reaches into the other.
|
|
272
|
+
//
|
|
273
|
+
// $VISUAL before $EDITOR — VISUAL is the variable for a full-screen
|
|
274
|
+
// editor and EDITOR is the fallback — and NO guess beyond them. In
|
|
275
|
+
// an environment that sets neither, dropping someone into an editor
|
|
276
|
+
// they did not choose is the product deciding for them.
|
|
277
|
+
//
|
|
278
|
+
// It never SUBMITS. What comes back is text in the composer and
|
|
279
|
+
// enter is still the human's: one key that both opens an editor and
|
|
280
|
+
// sends is a gesture with no way back.
|
|
281
|
+
const bin = process.env.VISUAL ?? process.env.EDITOR ?? "";
|
|
282
|
+
if (bin.trim() === "") {
|
|
283
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
284
|
+
bodyLog("no editor configured — set $VISUAL or $EDITOR to open one with ctrl+g");
|
|
285
|
+
ctx.input.prompt();
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// The process spawn lives in its own module, reached HERE and not
|
|
290
|
+
// imported at the top. The PTY manifest gate classifies a test by
|
|
291
|
+
// the RESOURCE closure of what it imports, so a static process
|
|
292
|
+
// import in the dispatcher moves every test that imports the
|
|
293
|
+
// dispatcher into the single-file-serial pool — it took `!cmd`'s
|
|
294
|
+
// millisecond-long parser test with it. Loading the module at the
|
|
295
|
+
// key makes the closure honest: importing the dispatcher cannot
|
|
296
|
+
// start a process.
|
|
297
|
+
//
|
|
298
|
+
// (The gate scans SOURCE, comments included, so naming the node
|
|
299
|
+
// module here in prose would reclassify this file just as surely as
|
|
300
|
+
// importing it. Deliberately over-wide — see the gate's own note.)
|
|
301
|
+
//
|
|
302
|
+
// Joining the chain is a FIX, not the price of that. Handing the
|
|
303
|
+
// terminal to a full-screen editor while a turn is streaming rows
|
|
304
|
+
// into that same terminal gives the child a screen kiso is still
|
|
305
|
+
// painting over. An idle chain resolves on the next microtask, so
|
|
306
|
+
// the press that is not mid-turn is unchanged.
|
|
307
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
308
|
+
const { runExternalEditor } = await import("./external-editor.js");
|
|
309
|
+
const root = join(kisoHome(), "tmp");
|
|
310
|
+
ctx.input.externalEdit?.((text) => runExternalEditor(bin, root, text, (msg) => body.notice(msg)));
|
|
311
|
+
// the external program drew over the screen; the model is the only
|
|
312
|
+
// authority on what should be there (R14).
|
|
313
|
+
body.reprint();
|
|
314
|
+
ctx.input.prompt();
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (trimmed === "\x14think") {
|
|
319
|
+
// §2.3 — ctrl+t folds the committed thinking blocks, and folds them
|
|
320
|
+
// back. DC-50's mechanism, not a second one: one boolean, then the
|
|
321
|
+
// session is reprinted, so the blocks already on screen obey the
|
|
322
|
+
// switch rather than only the next ones.
|
|
323
|
+
//
|
|
324
|
+
// The folded row is `foldThinking`'s, which is what the PIPE writes
|
|
325
|
+
// — so thinking has two renderings in the product and not three,
|
|
326
|
+
// and the pipe's byte-identity gate is this row's gate too.
|
|
327
|
+
//
|
|
328
|
+
// The live `thinking…` placeholder is NOT touched: it belongs to
|
|
329
|
+
// the live region, and this switch is about committed blocks.
|
|
330
|
+
body.toggleThinking();
|
|
331
|
+
ctx.input.prompt();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
173
334
|
if (trimmed === "\x18copy" || trimmed === "/copy") {
|
|
174
335
|
// E1 §3 — the last answer onto the clipboard, as RAW MARKDOWN.
|
|
175
336
|
//
|
|
@@ -493,12 +654,18 @@ export function dispatch(line, ctx) {
|
|
|
493
654
|
setLastBinding(binding);
|
|
494
655
|
setAgentModel(profile.model, profile.baseUrl);
|
|
495
656
|
setCurrentModelName(arg);
|
|
657
|
+
// §2.5: the ONE source a reload reads for the model — a
|
|
658
|
+
// switch made here must survive the rebuild.
|
|
659
|
+
setModelChoice(arg);
|
|
496
660
|
// OR-7 (owner, 2026-09-09): the status row is repainted BY the
|
|
497
661
|
// switch — it used to keep the old model until the next
|
|
498
662
|
// recap repainted it, so "takes effect on the next turn"
|
|
499
663
|
// read as "did not take". Painted BEFORE the notice so the
|
|
500
664
|
// frame that carries the notice already carries the row.
|
|
501
|
-
|
|
665
|
+
// DF-0311-F1: repainted with NO meter — the 0.31.1 dogfood
|
|
666
|
+
// (and the owner's) read `gpt-6-astra · CH 92%`, the new
|
|
667
|
+
// model beside the previous model's figure.
|
|
668
|
+
ctx.modelSwitched();
|
|
502
669
|
body.notice(`model → ${profName} (${profile.model}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
|
|
503
670
|
}
|
|
504
671
|
}
|
|
@@ -601,6 +768,17 @@ export function dispatch(line, ctx) {
|
|
|
601
768
|
});
|
|
602
769
|
return;
|
|
603
770
|
}
|
|
771
|
+
if (trimmed === "" && dock.active) {
|
|
772
|
+
// DC-57 (owner ruling 2026-09-10): a bare Enter on the EMPTY composer
|
|
773
|
+
// does nothing. It used to fall through to the exit below — a rule
|
|
774
|
+
// from the readline era, where an empty line on a pipe means the
|
|
775
|
+
// input is over — and the docked editor inherited it, so a stray
|
|
776
|
+
// Enter at the idle prompt ended the session (measured on 0.32.0).
|
|
777
|
+
// The pipe path keeps its byte-pinned end; the dock keeps `exit`
|
|
778
|
+
// and ctrl+c as the ways out.
|
|
779
|
+
ctx.input.prompt();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
604
782
|
if (trimmed === "exit" || trimmed === "") {
|
|
605
783
|
// PH-1a (finding PH-F11): closing the input MID-RUN killed the very
|
|
606
784
|
// surface a later approval would ask through ("readline was
|
|
@@ -634,6 +812,21 @@ export function dispatch(line, ctx) {
|
|
|
634
812
|
ctx.requestSwitch(newSessionId(sessionsDir()));
|
|
635
813
|
return;
|
|
636
814
|
}
|
|
815
|
+
if (trimmed === "/reload") {
|
|
816
|
+
// §2.5: extensions, skills and config, read again into a session that
|
|
817
|
+
// keeps its conversation. It is a REBUILD — the tool registry is
|
|
818
|
+
// built in the agent's constructor and has no unregister, so nothing
|
|
819
|
+
// short of constructing the agent again can drop a tool or add one.
|
|
820
|
+
// The durable log is untouched: a reload is invisible in the record,
|
|
821
|
+
// because nothing the model said or did changes.
|
|
822
|
+
if (ctx.isRunning()) {
|
|
823
|
+
body.notice("[/reload] a run is in flight — let it finish (esc stops it), then reload");
|
|
824
|
+
ctx.input.prompt();
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
ctx.requestReload();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
637
830
|
if (trimmed === "/resume" || trimmed.startsWith("/resume ")) {
|
|
638
831
|
// the mini-spec: the in-session door to the durable sessions —
|
|
639
832
|
// /resume <id> switches directly; bare /resume opens the SAME
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §2.4 — the RUN half of ctrl+g.
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher routes and this runs: it owns the temp file and the
|
|
5
|
+
* spawn, and knows nothing about the composer, the body or the session.
|
|
6
|
+
* Everything it needs arrives as an argument, which is what lets the
|
|
7
|
+
* dispatcher reach it through `await import(...)` at the call rather than
|
|
8
|
+
* a static import.
|
|
9
|
+
*
|
|
10
|
+
* That laziness is load-bearing, not stylistic. The PTY manifest gate
|
|
11
|
+
* classifies a test by the RESOURCE closure of what it imports, so a
|
|
12
|
+
* static `node:child_process` anywhere in dispatch.ts reclassifies every
|
|
13
|
+
* test that imports dispatch — a millisecond-long parser test for `!cmd`
|
|
14
|
+
* was moved into the single-file-serial pool by this module's first
|
|
15
|
+
* shape. A module the dispatcher only loads when the key is pressed is
|
|
16
|
+
* honestly outside that closure: nothing that merely imports the
|
|
17
|
+
* dispatcher can spawn anything.
|
|
18
|
+
*
|
|
19
|
+
* The failure contract is the whole point of the return type: `null`
|
|
20
|
+
* means KEEP WHAT WAS THERE. A failed edit must not eat what the human
|
|
21
|
+
* had already written.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Run `bin` on `text` and return what came back, or `null` when the edit
|
|
25
|
+
* failed — in which case `onFailure` has already been told, in one line,
|
|
26
|
+
* what went wrong.
|
|
27
|
+
*
|
|
28
|
+
* @param root the directory temp files live under (`$KISO_HOME/tmp`)
|
|
29
|
+
*/
|
|
30
|
+
export declare function runExternalEditor(bin: string, root: string, text: string, onFailure: (line: string) => void): string | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §2.4 — the RUN half of ctrl+g.
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher routes and this runs: it owns the temp file and the
|
|
5
|
+
* spawn, and knows nothing about the composer, the body or the session.
|
|
6
|
+
* Everything it needs arrives as an argument, which is what lets the
|
|
7
|
+
* dispatcher reach it through `await import(...)` at the call rather than
|
|
8
|
+
* a static import.
|
|
9
|
+
*
|
|
10
|
+
* That laziness is load-bearing, not stylistic. The PTY manifest gate
|
|
11
|
+
* classifies a test by the RESOURCE closure of what it imports, so a
|
|
12
|
+
* static `node:child_process` anywhere in dispatch.ts reclassifies every
|
|
13
|
+
* test that imports dispatch — a millisecond-long parser test for `!cmd`
|
|
14
|
+
* was moved into the single-file-serial pool by this module's first
|
|
15
|
+
* shape. A module the dispatcher only loads when the key is pressed is
|
|
16
|
+
* honestly outside that closure: nothing that merely imports the
|
|
17
|
+
* dispatcher can spawn anything.
|
|
18
|
+
*
|
|
19
|
+
* The failure contract is the whole point of the return type: `null`
|
|
20
|
+
* means KEEP WHAT WAS THERE. A failed edit must not eat what the human
|
|
21
|
+
* had already written.
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
/**
|
|
27
|
+
* Run `bin` on `text` and return what came back, or `null` when the edit
|
|
28
|
+
* failed — in which case `onFailure` has already been told, in one line,
|
|
29
|
+
* what went wrong.
|
|
30
|
+
*
|
|
31
|
+
* @param root the directory temp files live under (`$KISO_HOME/tmp`)
|
|
32
|
+
*/
|
|
33
|
+
export function runExternalEditor(bin, root, text, onFailure) {
|
|
34
|
+
// per INVOCATION, not per run: a directory per call is how the tree
|
|
35
|
+
// grew 400,000 of them once.
|
|
36
|
+
mkdirSync(root, { recursive: true });
|
|
37
|
+
const dir = mkdtempSync(join(root, "compose-"));
|
|
38
|
+
const file = join(dir, "message.md"); // .md so the editor lights it up
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(file, text, "utf8");
|
|
41
|
+
const r = spawnSync(bin, [file], { stdio: "inherit", shell: false });
|
|
42
|
+
if (r.error !== undefined || (r.status !== null && r.status !== 0)) {
|
|
43
|
+
onFailure(`[ctrl+g] ${bin} exited ${r.status ?? "abnormally"} — the composer is unchanged`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return readFileSync(file, "utf8").replace(/\n+$/, "");
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
onFailure(`[ctrl+g] ${err instanceof Error ? err.message : String(err)} — the composer is unchanged`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -30,13 +30,14 @@ import { createInterface } from "node:readline";
|
|
|
30
30
|
import { Readable } from "node:stream";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import { join } from "node:path";
|
|
33
|
-
import { Body, Editor, bannerLines, currentGround, resolveGround, setGround, escapeTerminal, extensionsBannerText, idColumn, idleStatus, interactivePrompt, palette, renderSessionLine, sessionListFooter, sessionListRow } from "@vincemakes/kiso-tui";
|
|
33
|
+
import { Body, Editor, PROMPT, bannerLines, currentGround, resolveGround, setGround, escapeTerminal, extensionsBannerText, idColumn, idleStatus, interactivePrompt, palette, renderSessionLine, sessionListFooter, sessionListRow } from "@vincemakes/kiso-tui";
|
|
34
34
|
import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, SessionStore, } from "@vincemakes/kiso-runtime";
|
|
35
35
|
import { createFauxProvider } from "@vincemakes/kiso-evals";
|
|
36
36
|
import { createCodingTools } from "@vincemakes/kiso-tools-node";
|
|
37
37
|
import { MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
|
|
38
|
+
import { breakerExtension } from "./breaker.js";
|
|
38
39
|
import { builtInLayer } from "./builtin.js";
|
|
39
|
-
import { agentModel, atFiles, body, bodyLog, kisoHome, builtInExtensions, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, projectExtensions, sessionStoreRef, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, setSessionStore, userExtensions, VERSION, lastBinding, acceptDrift, setAcceptDrift } from "./state.js";
|
|
40
|
+
import { agentModel, atFiles, body, bodyLog, codingToolOptions, kisoHome, builtInExtensions, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, modelChoice, projectExtensions, configModels, configuredWindow, agentBaseUrl, currentModelName, currentAgentExtensions, sessionStoreRef, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, setModelChoice, setSessionStore, userExtensions, VERSION, lastBinding, acceptDrift, setAcceptDrift } from "./state.js";
|
|
40
41
|
import { askUi, resolveProjectTrust } from "./trust-ui.js";
|
|
41
42
|
import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
|
|
42
43
|
import { fauxSkip, readFauxScript } from "./faux-glue.js";
|
|
@@ -78,6 +79,17 @@ function openInBrowser(url) {
|
|
|
78
79
|
// the URL is on screen; opening it is the person's fallback
|
|
79
80
|
}
|
|
80
81
|
}
|
|
82
|
+
/** LT-1: the stream watchdog's bound from the environment — a non-negative
|
|
83
|
+
* number of milliseconds (0 disables it); anything else is ignored. The
|
|
84
|
+
* PTY rigs use it to trip the watchdog in seconds against a stub that
|
|
85
|
+
* never finishes a stream. */
|
|
86
|
+
function streamIdleFromEnv() {
|
|
87
|
+
const raw = process.env.KISO_STREAM_IDLE_MS;
|
|
88
|
+
if (raw === undefined || raw === "")
|
|
89
|
+
return undefined;
|
|
90
|
+
const n = Number(raw);
|
|
91
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
92
|
+
}
|
|
81
93
|
/** OR-3: the commands that own their own stdin reader (a hidden key prompt,
|
|
82
94
|
* an OAuth paste prompt) or read nothing at all — never the editor.
|
|
83
95
|
* OR-9 adds `update`: npm inherits the terminal for the install's own
|
|
@@ -126,6 +138,17 @@ function readlineInput(rl) {
|
|
|
126
138
|
/* readline has no ctrl+o binding — ignored (W15 rides the
|
|
127
139
|
* editor path only). */
|
|
128
140
|
},
|
|
141
|
+
onThink() {
|
|
142
|
+
/* §2.3: same as ctrl+o above — readline has no binding, and a
|
|
143
|
+
* dock-less session has no committed rows to reprint. */
|
|
144
|
+
},
|
|
145
|
+
onEditor() {
|
|
146
|
+
/* §2.4: readline has no ctrl+g, and a piped session has no
|
|
147
|
+
* composer to hand over. */
|
|
148
|
+
},
|
|
149
|
+
externalEdit() {
|
|
150
|
+
/* §2.4: nothing to suspend — this path never took the terminal. */
|
|
151
|
+
},
|
|
129
152
|
onCopy() {
|
|
130
153
|
/* readline has no ctrl+x binding — ignored. `/copy` still
|
|
131
154
|
* works here: it is a typed command, not a key. */
|
|
@@ -210,6 +233,15 @@ function editorInput(editor) {
|
|
|
210
233
|
onExpand(cb) {
|
|
211
234
|
editor.onExpand(cb);
|
|
212
235
|
},
|
|
236
|
+
onThink(cb) {
|
|
237
|
+
editor.onThink(cb);
|
|
238
|
+
},
|
|
239
|
+
onEditor(cb) {
|
|
240
|
+
editor.onEditor(cb);
|
|
241
|
+
},
|
|
242
|
+
externalEdit(edit) {
|
|
243
|
+
editor.externalEdit(edit);
|
|
244
|
+
},
|
|
213
245
|
onCopy(cb) {
|
|
214
246
|
editor.onCopy(cb);
|
|
215
247
|
},
|
|
@@ -367,7 +399,15 @@ function makeLineInput() {
|
|
|
367
399
|
// here", which is the argument W6 made for the box and the only part
|
|
368
400
|
// of it that survives. A prompt character is a third thing saying
|
|
369
401
|
// the same thing, and it cost the row a column.
|
|
370
|
-
|
|
402
|
+
// OR-11 (a): ONE literal. The compositor draws this lead and the
|
|
403
|
+
// editor measures its rows against it; two copies is how they came
|
|
404
|
+
// to disagree by two columns in the first place.
|
|
405
|
+
const COMPOSER_LEAD = "";
|
|
406
|
+
dock.bindInput(() => editor.dockState(), COMPOSER_LEAD);
|
|
407
|
+
// …and BOTH renderers are live: the dock draws this row while it is
|
|
408
|
+
// active, the editor's own selfRender draws it (with the brick) when
|
|
409
|
+
// it is not. The budget follows whichever is drawing.
|
|
410
|
+
editor.setInputLead(() => (dock.active ? COMPOSER_LEAD : PROMPT));
|
|
371
411
|
// TUI2-R3v2 ②: the click hit-test's wiring — the compositor places
|
|
372
412
|
// the panel's option rows, so the compositor is what the editor asks
|
|
373
413
|
// where they are. Neither side computes the other's geometry.
|
|
@@ -591,6 +631,11 @@ function positiveIntEnv(name) {
|
|
|
591
631
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
592
632
|
}
|
|
593
633
|
async function makeAgent(sessionId, input, modelFlag) {
|
|
634
|
+
// §2.5: the ONE source a reload reads for the model, seeded here from
|
|
635
|
+
// the startup flag. Without this a session started with `--model X`
|
|
636
|
+
// would silently revert to the env/config default on its first reload.
|
|
637
|
+
if (modelFlag !== undefined)
|
|
638
|
+
setModelChoice(modelFlag);
|
|
594
639
|
// E3: the project-level trust gate runs BEFORE any extension load (the
|
|
595
640
|
// mcp/skills merges must be in the env when the user-level extensions
|
|
596
641
|
// load). Untrusted project capability is never loaded — never silently.
|
|
@@ -652,16 +697,19 @@ async function makeAgent(sessionId, input, modelFlag) {
|
|
|
652
697
|
// generated extension into it so a first-time rule joins the chain
|
|
653
698
|
// at the NEXT run (the run's policies are fixed at its start; run.ts
|
|
654
699
|
// re-reads the config's extensions array per run).
|
|
655
|
-
|
|
700
|
+
// LT-2: the loop breaker at the chain HEAD — it speaks first when it speaks,
|
|
701
|
+
// so `decidedBy` names it; a deny there beats every tier, bypass included.
|
|
702
|
+
const extensions = [breakerExtension(), ...modeExtensions(), ...loadedExtensions];
|
|
656
703
|
setCurrentAgentExtensions(extensions);
|
|
657
704
|
// E6: the run-start context policy (captured once — exactOptionalPropertyTypes).
|
|
658
705
|
const contextPolicy = contextPolicyFromEnv();
|
|
706
|
+
const idleFromEnv = streamIdleFromEnv(); // read once: a narrowed const, not a call per spread
|
|
659
707
|
const definition = {
|
|
660
708
|
model,
|
|
661
709
|
store,
|
|
662
710
|
// Area 5: the coding tools are bound to the workspace — every path
|
|
663
711
|
// they touch is canonicalized inside cwd, escapes are refused.
|
|
664
|
-
tools: [...createCodingTools(
|
|
712
|
+
tools: [...createCodingTools(codingToolOptions())], // DC-49 — the options live in state.ts, shared with the `!` command's runner
|
|
665
713
|
// Modes: the five tiers ride the E1 policy chain (mode:<tier>
|
|
666
714
|
// extensions, current tier first) — the old static PERMISSION_POLICY
|
|
667
715
|
// is gone, its semantics live in the "default" tier. The banner
|
|
@@ -706,8 +754,13 @@ async function makeAgent(sessionId, input, modelFlag) {
|
|
|
706
754
|
...(sessionId !== undefined ? { promptCacheKey: sessionId } : {}),
|
|
707
755
|
...(resolved.profile.baseUrl !== undefined ? { baseUrl: resolved.profile.baseUrl } : {}),
|
|
708
756
|
...(resolved.profile.promptCaching !== undefined ? { promptCaching: resolved.profile.promptCaching } : {}),
|
|
757
|
+
// LT-1: the profile's stream watchdog bound, if it states one
|
|
758
|
+
...(resolved.profile.streamIdleMs !== undefined ? { streamIdleMs: resolved.profile.streamIdleMs } : {}),
|
|
709
759
|
}
|
|
710
760
|
: { adapter: createFauxProvider(readFauxScript().slice(fauxSkipTurns)) }),
|
|
761
|
+
// LT-1: KISO_STREAM_IDLE_MS (the test rigs' knob) beats the profile —
|
|
762
|
+
// the last spread wins, which is why it sits after the profile's.
|
|
763
|
+
...(idleFromEnv !== undefined ? { streamIdleMs: idleFromEnv } : {}),
|
|
711
764
|
};
|
|
712
765
|
return createAgent(definition);
|
|
713
766
|
}
|
|
@@ -794,12 +847,137 @@ class CliUsageError extends Error {
|
|
|
794
847
|
* line (the previous conversation stays resumable — clear/switch
|
|
795
848
|
* never erase history). Faux sessions re-arm the scripted adapter at
|
|
796
849
|
* the NEW session's durable position, exactly like the picker path. */
|
|
797
|
-
|
|
850
|
+
/** §2.5 — how many skills are IN FORCE, taken from the extension that
|
|
851
|
+
* indexed them rather than by walking the directory again. The first
|
|
852
|
+
* version re-scanned `KISO_SKILLS_DIR` for one number in one log line,
|
|
853
|
+
* which is a second answer to a question already answered and free to
|
|
854
|
+
* disagree with the first (lead's review of 5bee644, observation 2).
|
|
855
|
+
* An extension that reports no count is reported as none, never guessed. */
|
|
856
|
+
function skillCount() {
|
|
857
|
+
// `skills` is declared on the extension's own published type
|
|
858
|
+
// (extensions/skills/index.d.ts), not invented here.
|
|
859
|
+
const skills = loadedExtensions.find((e) => e.name === "skills");
|
|
860
|
+
return skills?.skills ?? 0;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* §2.5 — the reload: the agent is CONSTRUCTED AGAIN on the same session.
|
|
864
|
+
*
|
|
865
|
+
* Nothing short of this reloads the whole surface. `run.ts` composes the
|
|
866
|
+
* system prompt and the approval chain per run, so mutating the shared
|
|
867
|
+
* extensions array reaches those — but the tool registry is built in the
|
|
868
|
+
* agent's constructor and has no unregister, and hooks are frozen into
|
|
869
|
+
* the session's config. Half a reload is worse than none: a prompt that
|
|
870
|
+
* announces a skill whose tool is not in the table is the DC-19 family.
|
|
871
|
+
*
|
|
872
|
+
* LOAD FIRST, SWAP ONLY ON SUCCESS. `loadExtensions` is deliberately
|
|
873
|
+
* loud, so tearing the old agent down first would leave a typo in an
|
|
874
|
+
* extension holding a live session with no agent and no way back. The
|
|
875
|
+
* cost is that two sets of MCP servers are briefly alive; a stdio server
|
|
876
|
+
* holding an exclusive resource — a port, a lock — fails to start twice
|
|
877
|
+
* and so fails the reload cleanly, which is the honest outcome.
|
|
878
|
+
*
|
|
879
|
+
* The conversation survives because the id does not change and its truth
|
|
880
|
+
* is the durable log on disk. The record never moves: a reload is
|
|
881
|
+
* invisible in it, because nothing the model said or did changes.
|
|
882
|
+
*/
|
|
883
|
+
async function reloadAgent(old, id, input) {
|
|
884
|
+
// Snapshot BEFORE makeAgent: it overwrites every one of these, so the
|
|
885
|
+
// old set would be unreachable by the time we needed to dispose it.
|
|
886
|
+
const oldLoaded = loadedExtensions;
|
|
887
|
+
const oldBuiltIn = builtInExtensions;
|
|
888
|
+
const oldUser = userExtensions;
|
|
889
|
+
const oldProject = projectExtensions;
|
|
890
|
+
const oldTemps = mergedTempPaths.splice(0);
|
|
891
|
+
// makeAgent publishes the config side BEFORE it can fail on the model,
|
|
892
|
+
// so a failure past the extension load would leave a NEW window, model
|
|
893
|
+
// name and profile table beside the OLD agent — and the failure line
|
|
894
|
+
// says nothing changed. Rather than soften the line, the snapshot makes
|
|
895
|
+
// it true: a failed reload is atomic, which is what load-then-swap
|
|
896
|
+
// promises. (Lead's review of 5bee644, observation 1.)
|
|
897
|
+
const oldCfg = {
|
|
898
|
+
// RL-F6: makeAgent publishes this BEFORE createAgent, and createAgent
|
|
899
|
+
// throws on a tool-name collision — a user extension exposing
|
|
900
|
+
// `read_file` is enough — so a failure CAN land after it. Left
|
|
901
|
+
// unrestored, the new array stands beside the old agent, whose config
|
|
902
|
+
// holds the old one by reference, and the don't-ask-again writer
|
|
903
|
+
// mutates whatever this is: a rule granted after a failed reload goes
|
|
904
|
+
// into an array nothing reads. RL-F3's family one level up, and the
|
|
905
|
+
// same symptom — the human grants a rule and is asked again on the
|
|
906
|
+
// very next identical call.
|
|
907
|
+
agentExtensions: currentAgentExtensions,
|
|
908
|
+
merged: mergedConfig,
|
|
909
|
+
models: configModels,
|
|
910
|
+
window: configuredWindow,
|
|
911
|
+
faux: currentFaux,
|
|
912
|
+
modelName: currentModelName,
|
|
913
|
+
choice: modelChoice,
|
|
914
|
+
agent: agentModel,
|
|
915
|
+
endpoint: agentBaseUrl,
|
|
916
|
+
delegation: process.env.KISO_DELEGATION_CONFIG_JSON,
|
|
917
|
+
};
|
|
918
|
+
let next;
|
|
919
|
+
try {
|
|
920
|
+
next = await makeAgent(id, input, modelChoice);
|
|
921
|
+
}
|
|
922
|
+
catch (err) {
|
|
923
|
+
// Nothing is swapped. Clean up whatever the failed attempt managed
|
|
924
|
+
// to create, put the old set's own temp paths back so exit still
|
|
925
|
+
// removes them, and restore the lists in case makeAgent got far
|
|
926
|
+
// enough to publish new ones.
|
|
927
|
+
for (const p of mergedTempPaths.splice(0)) {
|
|
928
|
+
try {
|
|
929
|
+
rmSync(p, { recursive: true, force: true });
|
|
930
|
+
}
|
|
931
|
+
catch {
|
|
932
|
+
// best-effort — the temp dir would be reaped by the OS
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
mergedTempPaths.push(...oldTemps);
|
|
936
|
+
setExtensionLists(oldBuiltIn, oldUser, oldProject, oldLoaded);
|
|
937
|
+
setCurrentAgentExtensions(oldCfg.agentExtensions);
|
|
938
|
+
setMergedConfig(oldCfg.merged);
|
|
939
|
+
setConfigModels(oldCfg.models);
|
|
940
|
+
setConfiguredWindow(oldCfg.window);
|
|
941
|
+
setCurrentFaux(oldCfg.faux);
|
|
942
|
+
setCurrentModelName(oldCfg.modelName);
|
|
943
|
+
setModelChoice(oldCfg.choice);
|
|
944
|
+
setAgentModel(oldCfg.agent, oldCfg.endpoint);
|
|
945
|
+
if (oldCfg.delegation === undefined)
|
|
946
|
+
delete process.env.KISO_DELEGATION_CONFIG_JSON;
|
|
947
|
+
else
|
|
948
|
+
process.env.KISO_DELEGATION_CONFIG_JSON = oldCfg.delegation;
|
|
949
|
+
bodyLog(`[reload] ${err instanceof Error ? err.message : String(err)} — nothing changed, the previous set is still in force`);
|
|
950
|
+
return old;
|
|
951
|
+
}
|
|
952
|
+
// The new set is built and sound; only now does the old one go.
|
|
953
|
+
old.close();
|
|
954
|
+
await disposeExtensions(oldLoaded);
|
|
955
|
+
for (const p of oldTemps) {
|
|
956
|
+
try {
|
|
957
|
+
rmSync(p, { recursive: true, force: true });
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
// best-effort — the temp dir would be reaped by the OS
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
bodyLog(`[reload] ${loadedExtensions.length} extensions, ${skillCount()} skills — the conversation is unchanged`);
|
|
964
|
+
return next;
|
|
965
|
+
}
|
|
966
|
+
async function chatLoop(first, firstId, input, autoCompact) {
|
|
798
967
|
let id = firstId;
|
|
799
968
|
let prev = null;
|
|
969
|
+
let agent = first;
|
|
970
|
+
// §2.5: a rebuilt agent re-enters on the SAME conversation, so it prints
|
|
971
|
+
// neither the opening banner nor the "switched — previous" line. The
|
|
972
|
+
// reload said what it did; repeating the session's opening would read
|
|
973
|
+
// as a new session, which is the one thing it is not.
|
|
974
|
+
let rebuilt = false;
|
|
800
975
|
for (;;) {
|
|
801
976
|
const session = await agent.session({ id, ...(acceptDrift() ? { acceptDrift: true } : {}) });
|
|
802
|
-
if (
|
|
977
|
+
if (rebuilt) {
|
|
978
|
+
rebuilt = false;
|
|
979
|
+
}
|
|
980
|
+
else if (prev === null) {
|
|
803
981
|
bodyLog(`session ${id}\n`);
|
|
804
982
|
// REL-0152-D5: a session with history says what that history WAS.
|
|
805
983
|
// Resuming used to print this one line and drop you at an empty
|
|
@@ -854,6 +1032,11 @@ async function chatLoop(agent, firstId, input, autoCompact) {
|
|
|
854
1032
|
const end = await chat(session, currentFaux, input, autoCompact, nav);
|
|
855
1033
|
if (end.next === "exit")
|
|
856
1034
|
return;
|
|
1035
|
+
if (end.next === "reload") {
|
|
1036
|
+
agent = await reloadAgent(agent, id, input);
|
|
1037
|
+
rebuilt = true;
|
|
1038
|
+
continue; // the same id, the same conversation, a new agent
|
|
1039
|
+
}
|
|
857
1040
|
prev = id;
|
|
858
1041
|
id = end.id;
|
|
859
1042
|
}
|