@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.
@@ -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
- dock.bindInput(() => editor.dockState(), "");
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
- const extensions = [...modeExtensions(), ...loadedExtensions];
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({ workspaceRoot: process.cwd(), excludeRoots: [kisoHome()] })], // DC-49
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
- async function chatLoop(agent, firstId, input, autoCompact) {
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 (prev === null) {
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
- const existed = existsSync(file);
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
- `export const RULES = new Set(${JSON.stringify([rule])});`,
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: a file that existed at startup was
189
- // imported by the loader — ONE namespace — so mutating the exported
190
- // RULES Set is live NOW (the current run's chain reads the same Set).
191
- const mod = (await import(pathToFileURL(file).href));
192
- if (mod.RULES !== undefined && !mod.RULES.has(rule))
193
- mod.RULES.add(rule);
194
- // A FIRST-time rule (a file that did not exist at startup) is not yet
195
- // in the current run's chain — the chain is fixed at a run's start —
196
- // so the generated extension joins the NEXT run's policies (run.ts
197
- // re-reads the session config's extensions array, shared by
198
- // reference). The honest story: a new rule applies from the next run.
199
- if (!existed && mod.default !== undefined) {
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
- const userPath = process.env.KISO_MCP_CONFIG ?? join(kisoHome(), "mcp.json");
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
- const temp = join(tmpdir(), `kiso-mcp-merged-${process.pid}.json`);
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(temp);
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
- const userDir = process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
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);