@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/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
- ctx.paintIdle();
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
- 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
  }