@vincemakes/kiso-code 0.31.1 → 0.32.0
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 +184 -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
|
@@ -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
|
}
|
package/dist/state.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ import type { KisoExtension, StoreRecord } from "@vincemakes/kiso-runtime";
|
|
|
12
12
|
* env vars (KISO_EXTENSIONS_DIR / KISO_MCP_CONFIG / KISO_SKILLS_DIR)
|
|
13
13
|
* still override their own path; nothing hard-codes ~/.kiso anymore. */
|
|
14
14
|
export declare function kisoHome(): string;
|
|
15
|
+
/** DC-49 — the workspace the coding tools may touch, and the one place it
|
|
16
|
+
* is decided. The session's own tool set and the `!` command's runner both
|
|
17
|
+
* need it, and two copies of a ruling-bearing literal is how a later DC-49
|
|
18
|
+
* amendment reaches one caller and not the other. */
|
|
19
|
+
export declare function codingToolOptions(): {
|
|
20
|
+
readonly workspaceRoot: string;
|
|
21
|
+
readonly excludeRoots: readonly string[];
|
|
22
|
+
};
|
|
15
23
|
export declare function sessionsDir(): string;
|
|
16
24
|
/** E1: the extension scan directory — KISO_EXTENSIONS_DIR overrides. */
|
|
17
25
|
export declare function extensionsDir(): string;
|
|
@@ -77,6 +85,15 @@ export interface LineInput {
|
|
|
77
85
|
/** W15: the expand key (ctrl+o) — the chain-level action, never the
|
|
78
86
|
* editor's own interpretation. */
|
|
79
87
|
onExpand(cb: () => void): void;
|
|
88
|
+
/** §2.3: the thinking key (ctrl+t) — the same chain-level shape as
|
|
89
|
+
* onExpand. Optional: the readline input has no such key, and a
|
|
90
|
+
* dock-less session has nothing to reprint. */
|
|
91
|
+
onThink?(cb: () => void): void;
|
|
92
|
+
/** §2.4: the external-editor key (ctrl+g). */
|
|
93
|
+
onEditor?(cb: () => void): void;
|
|
94
|
+
/** §2.4: hand the terminal over, run `edit`, take it back. The editor
|
|
95
|
+
* owns the handover; the CLI owns the spawn. */
|
|
96
|
+
externalEdit?(edit: (text: string) => string | null): void;
|
|
80
97
|
/** E1 §3: the copy key (ctrl+x) — the chain-level action, never the
|
|
81
98
|
* composer's own. The readline input ignores it; `/copy` is the
|
|
82
99
|
* route that works everywhere. */
|
|
@@ -178,6 +195,24 @@ export declare let configModels: Readonly<Record<string, import("./config.js").M
|
|
|
178
195
|
export declare function setConfigModels(models: Readonly<Record<string, import("./config.js").ModelProfile>>): void;
|
|
179
196
|
/** The name of the model currently driving the session ("faux" or the
|
|
180
197
|
* profile name / provider/model write / env model). */
|
|
198
|
+
/**
|
|
199
|
+
* §2.5 — the profile the human CHOSE by name, and the only source a
|
|
200
|
+
* rebuild may read for the model.
|
|
201
|
+
*
|
|
202
|
+
* Not `currentModelName`: that holds whatever resolution produced, and
|
|
203
|
+
* an env-resolved session's name ("gpt-4o", "claude-sonnet-5") is not a
|
|
204
|
+
* key in `models`, so handing it back to `resolveModel` throws. Not the
|
|
205
|
+
* startup flag either, or a `/model` switch would silently revert on the
|
|
206
|
+
* first reload — the effort-axis round produced that bug once already
|
|
207
|
+
* from having two sources for the model.
|
|
208
|
+
*
|
|
209
|
+
* One variable, seeded by `makeAgent` from the startup flag and
|
|
210
|
+
* overwritten by `/model`. Undefined means "resolve exactly as this
|
|
211
|
+
* process did at startup", which is the right answer for a session that
|
|
212
|
+
* never named a profile.
|
|
213
|
+
*/
|
|
214
|
+
export declare let modelChoice: string | undefined;
|
|
215
|
+
export declare function setModelChoice(value: string | undefined): void;
|
|
181
216
|
export declare let currentModelName: string;
|
|
182
217
|
export declare function setCurrentModelName(value: string): void;
|
|
183
218
|
/** E1: the extensions loaded by makeAgent — their names feed the banner. */
|
package/dist/state.js
CHANGED
|
@@ -18,6 +18,13 @@ import { AT_CAP, AT_SKIP, Dock } from "@vincemakes/kiso-tui";
|
|
|
18
18
|
export function kisoHome() {
|
|
19
19
|
return process.env.KISO_HOME ?? join(homedir(), ".kiso");
|
|
20
20
|
}
|
|
21
|
+
/** DC-49 — the workspace the coding tools may touch, and the one place it
|
|
22
|
+
* is decided. The session's own tool set and the `!` command's runner both
|
|
23
|
+
* need it, and two copies of a ruling-bearing literal is how a later DC-49
|
|
24
|
+
* amendment reaches one caller and not the other. */
|
|
25
|
+
export function codingToolOptions() {
|
|
26
|
+
return { workspaceRoot: process.cwd(), excludeRoots: [kisoHome()] };
|
|
27
|
+
}
|
|
21
28
|
export function sessionsDir() {
|
|
22
29
|
return join(kisoHome(), "sessions");
|
|
23
30
|
}
|
|
@@ -246,6 +253,26 @@ export function setConfigModels(models) {
|
|
|
246
253
|
}
|
|
247
254
|
/** The name of the model currently driving the session ("faux" or the
|
|
248
255
|
* profile name / provider/model write / env model). */
|
|
256
|
+
/**
|
|
257
|
+
* §2.5 — the profile the human CHOSE by name, and the only source a
|
|
258
|
+
* rebuild may read for the model.
|
|
259
|
+
*
|
|
260
|
+
* Not `currentModelName`: that holds whatever resolution produced, and
|
|
261
|
+
* an env-resolved session's name ("gpt-4o", "claude-sonnet-5") is not a
|
|
262
|
+
* key in `models`, so handing it back to `resolveModel` throws. Not the
|
|
263
|
+
* startup flag either, or a `/model` switch would silently revert on the
|
|
264
|
+
* first reload — the effort-axis round produced that bug once already
|
|
265
|
+
* from having two sources for the model.
|
|
266
|
+
*
|
|
267
|
+
* One variable, seeded by `makeAgent` from the startup flag and
|
|
268
|
+
* overwritten by `/model`. Undefined means "resolve exactly as this
|
|
269
|
+
* process did at startup", which is the right answer for a session that
|
|
270
|
+
* never named a profile.
|
|
271
|
+
*/
|
|
272
|
+
export let modelChoice;
|
|
273
|
+
export function setModelChoice(value) {
|
|
274
|
+
modelChoice = value;
|
|
275
|
+
}
|
|
249
276
|
export let currentModelName = "faux";
|
|
250
277
|
export function setCurrentModelName(value) {
|
|
251
278
|
currentModelName = value;
|
package/dist/trust-ui.js
CHANGED
|
@@ -162,17 +162,38 @@ export function askUi(input) {
|
|
|
162
162
|
export async function addDontAskAgainRule(rule) {
|
|
163
163
|
const dir = extensionsDir();
|
|
164
164
|
const file = join(dir, "dont-ask-again.mjs");
|
|
165
|
-
|
|
165
|
+
// §2.5: the LIVE extension, found by name in the array the chain reads.
|
|
166
|
+
// This is the only handle on the rules that is guaranteed to be the one
|
|
167
|
+
// in force, and finding it also tells us whether this is a first rule.
|
|
168
|
+
const at = currentAgentExtensions.findIndex((e) => e.name === "dont-ask-again");
|
|
169
|
+
const live = at >= 0 ? currentAgentExtensions[at] : undefined;
|
|
170
|
+
// Every rule the session already grants, plus this one (RL-F2: the file
|
|
171
|
+
// used to be rewritten with ONLY the newest rule, so a second grant
|
|
172
|
+
// erased the first from disk — invisible in-session, because the
|
|
173
|
+
// in-memory Set kept both, and only discovered at the next start).
|
|
174
|
+
//
|
|
175
|
+
// RL-F5: a file generated BEFORE this change exports RULES and puts
|
|
176
|
+
// nothing on the extension, so `live.rules` is undefined for it and a
|
|
177
|
+
// union starting from nothing would rewrite the file with this grant
|
|
178
|
+
// alone — every rule the human gave under 0.31.x gone, in exactly the
|
|
179
|
+
// way RL-F2 was invisible. The rules come out of the file instead, and
|
|
180
|
+
// the migration costs one grant.
|
|
181
|
+
const existing = live?.rules !== undefined ? [...live.rules] : rulesInFile(file);
|
|
182
|
+
const rules = [...new Set([...existing, rule])].sort();
|
|
166
183
|
const module = [
|
|
167
184
|
"// generated by kiso — the \"2 Yes, don't ask again for <tool>\" rule.",
|
|
168
185
|
"// HUMAN-EDITABLE and HUMAN-DELETABLE — this file IS the revocation path:",
|
|
169
186
|
"// delete a rule from the set (or the whole file) to be asked again.",
|
|
170
187
|
"// ALLOW-ONLY by design (R3): never emits deny or ask — the mode moat",
|
|
171
188
|
"// and the safe-defaults moat keep their teeth.",
|
|
172
|
-
`
|
|
189
|
+
`const RULES = new Set(${JSON.stringify(rules)});`,
|
|
173
190
|
"",
|
|
174
191
|
"export default {",
|
|
175
192
|
' name: "dont-ask-again",',
|
|
193
|
+
" // the live handle: kiso mutates THIS set to make a new rule take",
|
|
194
|
+
" // effect without a reload. It is the same object the decide below",
|
|
195
|
+
" // closes over, which is the whole point.",
|
|
196
|
+
" rules: RULES,",
|
|
176
197
|
" approvals: [",
|
|
177
198
|
" {",
|
|
178
199
|
" decide(call) {",
|
|
@@ -185,21 +206,57 @@ export async function addDontAskAgainRule(rule) {
|
|
|
185
206
|
].join("\n");
|
|
186
207
|
mkdirSync(dir, { recursive: true });
|
|
187
208
|
writeFileSync(file, module, "utf8");
|
|
188
|
-
// The live in-session effect
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
if (
|
|
209
|
+
// The live in-session effect. This used to re-import the plain file URL
|
|
210
|
+
// and mutate the module's exported Set, on the stated assumption that a
|
|
211
|
+
// file the loader imported is ONE namespace. §2.5's loader appends a
|
|
212
|
+
// per-load nonce so `/reload` can pick up edited code, and that
|
|
213
|
+
// assumption died with it: the loader holds `file?load=N` and a plain
|
|
214
|
+
// import is the startup namespace. A rule granted after any reload
|
|
215
|
+
// would mutate a Set nothing reads — the human clicks don't-ask-again
|
|
216
|
+
// and is asked again on the very next identical call.
|
|
217
|
+
//
|
|
218
|
+
// So there is no second import. The rule is added THROUGH the live
|
|
219
|
+
// extension, which is one source of truth by construction.
|
|
220
|
+
if (live?.rules !== undefined) {
|
|
221
|
+
live.rules.add(rule);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// Either a FIRST rule (no such extension loaded) or the RL-F5 migration
|
|
225
|
+
// (a legacy extension is loaded but has no live handle). The freshly
|
|
226
|
+
// written file is imported and joins the chain — REPLACING the legacy
|
|
227
|
+
// entry rather than sitting beside it, because two extensions of the
|
|
228
|
+
// same name in the chain is a second source of truth and the whole point
|
|
229
|
+
// of the handle is that there is one. The chain is fixed at a run's
|
|
230
|
+
// start, so a first rule applies from the NEXT run (run.ts re-reads the
|
|
231
|
+
// session config's extensions array, shared by reference).
|
|
232
|
+
//
|
|
233
|
+
// The nonce is the loader's own lesson applied here: a plain file URL is
|
|
234
|
+
// cached forever, so a second grant in one process would import the
|
|
235
|
+
// bytes of the first.
|
|
236
|
+
const mod = (await import(`${pathToFileURL(file).href}?grant=${(grantSerial += 1)}`));
|
|
237
|
+
if (mod.default === undefined)
|
|
238
|
+
return;
|
|
239
|
+
if (at >= 0)
|
|
240
|
+
currentAgentExtensions[at] = mod.default;
|
|
241
|
+
else
|
|
200
242
|
currentAgentExtensions.push(mod.default);
|
|
243
|
+
}
|
|
244
|
+
/** RL-F5 — the rules a generated file already holds. The `new Set([...])`
|
|
245
|
+
* literal is JSON, which is what makes reading it back honest rather than
|
|
246
|
+
* a parser. An unreadable or hand-edited-past-recognition file yields
|
|
247
|
+
* nothing, and the new rule stands alone — the file is documented as
|
|
248
|
+
* human-editable, so kiso must not fail on what a human wrote there. */
|
|
249
|
+
function rulesInFile(file) {
|
|
250
|
+
try {
|
|
251
|
+
const m = /new Set\((\[[^\]]*\])\)/.exec(readFileSync(file, "utf8"));
|
|
252
|
+
return m === null ? [] : JSON.parse(m[1]);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return [];
|
|
201
256
|
}
|
|
202
257
|
}
|
|
258
|
+
/** RL-F5: distinguishes each grant's import of the generated file. */
|
|
259
|
+
let grantSerial = 0;
|
|
203
260
|
/**
|
|
204
261
|
* W21 — the §3.5 fix-hint table: per speaker, the ONE-line path to stop
|
|
205
262
|
* being asked. The hint names the FIX, never a bypass ("switch to
|
|
@@ -309,8 +366,24 @@ function readMcpConfig(path) {
|
|
|
309
366
|
}
|
|
310
367
|
/** Merge user-level + project-level mcp.json into one temp file and point
|
|
311
368
|
* KISO_MCP_CONFIG at it — the mcp extension reads it at load time. */
|
|
369
|
+
/**
|
|
370
|
+
* RL-F4 — the user mcp.json as it was BEFORE any merge, and RL-F1's exact
|
|
371
|
+
* sibling one function up. `applyMcpMerge` read `KISO_MCP_CONFIG` and then
|
|
372
|
+
* assigned the merged file to it, so a second merge — which is what a
|
|
373
|
+
* reload is — took the first merged file as the user config. That file
|
|
374
|
+
* already holds the project's servers, so the "exists in both" check
|
|
375
|
+
* throws for every one of them and EVERY reload fails in exactly the
|
|
376
|
+
* projects that carry MCP servers.
|
|
377
|
+
*
|
|
378
|
+
* Found by the lead reviewing the commit that fixed RL-F1 in the function
|
|
379
|
+
* below. The lesson is the sibling, not the loop: a defect found in one
|
|
380
|
+
* function is a question to ask of every function beside it, and I fixed
|
|
381
|
+
* one merge without looking at the other.
|
|
382
|
+
*/
|
|
383
|
+
let originalUserMcpPath;
|
|
312
384
|
function applyMcpMerge(root) {
|
|
313
|
-
|
|
385
|
+
originalUserMcpPath ??= process.env.KISO_MCP_CONFIG ?? join(kisoHome(), "mcp.json");
|
|
386
|
+
const userPath = originalUserMcpPath;
|
|
314
387
|
const user = readMcpConfig(userPath);
|
|
315
388
|
const project = readMcpConfig(join(root, "mcp.json"));
|
|
316
389
|
const userServers = user.mcpServers ?? {};
|
|
@@ -323,17 +396,37 @@ function applyMcpMerge(root) {
|
|
|
323
396
|
}
|
|
324
397
|
}
|
|
325
398
|
const merged = { mcpServers: { ...userServers, ...projectServers } };
|
|
326
|
-
|
|
399
|
+
// RL-F4, second half: the path used to be `kiso-mcp-merged-<pid>.json`,
|
|
400
|
+
// the SAME file on every merge in one process — so a reload's cleanup of
|
|
401
|
+
// the paths the previous set owned deleted the file the new set had just
|
|
402
|
+
// been merged into. One directory per merge, and the directory is what is
|
|
403
|
+
// recorded, so the cleanup removes exactly what that merge created.
|
|
404
|
+
const dir = mkdtempSync(join(tmpdir(), "kiso-mcp-"));
|
|
405
|
+
const temp = join(dir, "mcp.json");
|
|
327
406
|
writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
328
407
|
process.env.KISO_MCP_CONFIG = temp;
|
|
329
|
-
mergedTempPaths.push(
|
|
408
|
+
mergedTempPaths.push(dir);
|
|
330
409
|
}
|
|
331
410
|
/** Merge user-level + project-level skills into one temp scan dir (project
|
|
332
411
|
* skill dirs symlinked first; a name in both → project wins + a stderr
|
|
333
412
|
* note) and point KISO_SKILLS_DIR at it — the skills extension's existing
|
|
334
413
|
* scan reads it at load time and per read_skill call. */
|
|
414
|
+
/**
|
|
415
|
+
* §2.5 — the user skills directory as it was BEFORE any merge.
|
|
416
|
+
*
|
|
417
|
+
* `applySkillsMerge` ends by pointing `KISO_SKILLS_DIR` at the merged
|
|
418
|
+
* directory it just made. Read twice — which is exactly what a reload
|
|
419
|
+
* does — that is a loop: the second merge takes the FIRST merge as its
|
|
420
|
+
* user directory. A skill deleted from the real user directory would
|
|
421
|
+
* survive forever, and each merge would produce a temp directory the
|
|
422
|
+
* previous one's symlinks depend on, so none of them could ever be
|
|
423
|
+
* removed. This tree has already lost an afternoon to 449,956 stale
|
|
424
|
+
* temp directories.
|
|
425
|
+
*/
|
|
426
|
+
let originalUserSkillsDir;
|
|
335
427
|
function applySkillsMerge(root) {
|
|
336
|
-
|
|
428
|
+
originalUserSkillsDir ??= process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
|
|
429
|
+
const userDir = originalUserSkillsDir;
|
|
337
430
|
const projectDir = join(root, "skills");
|
|
338
431
|
const merged = mkdtempSync(join(tmpdir(), "kiso-skills-"));
|
|
339
432
|
mergedTempPaths.push(merged);
|