@vincemakes/kiso-code 0.31.0 → 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/index.js CHANGED
@@ -24,25 +24,27 @@
24
24
  * makeAgent, and main.
25
25
  */
26
26
  import { appendFileSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
27
+ import { spawn, spawnSync } from "node:child_process";
27
28
  import { newSessionId } from "./session-id.js";
28
29
  import { createInterface } from "node:readline";
29
30
  import { Readable } from "node:stream";
30
31
  import { fileURLToPath } from "node:url";
31
32
  import { join } from "node:path";
32
- 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";
33
34
  import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, SessionStore, } from "@vincemakes/kiso-runtime";
34
35
  import { createFauxProvider } from "@vincemakes/kiso-evals";
35
36
  import { createCodingTools } from "@vincemakes/kiso-tools-node";
36
37
  import { MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
38
+ import { breakerExtension } from "./breaker.js";
37
39
  import { builtInLayer } from "./builtin.js";
38
- 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";
39
41
  import { askUi, resolveProjectTrust } from "./trust-ui.js";
40
42
  import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
41
43
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
42
- import { chat, contextWindowTokens, displayCtxRatio } from "./chat.js";
44
+ import { chat, contextWindowTokens, displayCtxRatio, statusModelLabel } from "./chat.js";
43
45
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
44
46
  import { oauthTokenThunk } from "./auth/token.js";
45
- import { checkForUpdate } from "./update-check.js";
47
+ import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
46
48
  import { resume } from "./resume.js";
47
49
  import { resumeTail } from "./resume-tail.js";
48
50
  import { armByteTrace } from "./byte-trace.js";
@@ -62,6 +64,40 @@ export { applyProjectMerges } from "./trust-ui.js";
62
64
  * exits, so the turn runs to its terminal and nothing else is read.
63
65
  * Built on an EMPTY readline so every other member keeps the pipe
64
66
  * path's exact semantics (auto-denied asks, no chips, no pops). */
67
+ /** OR-4: hand a URL to the platform's opener, detached, and never let a
68
+ * missing or failing opener touch the sign-in — the printed URL is the
69
+ * path that always works. macOS `open`, Linux `xdg-open`; elsewhere the
70
+ * URL stays printed (Windows is unsupported by the CLI as a whole). */
71
+ function openInBrowser(url) {
72
+ const opener = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
73
+ if (opener === null)
74
+ return;
75
+ try {
76
+ spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
77
+ }
78
+ catch {
79
+ // the URL is on screen; opening it is the person's fallback
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
+ }
93
+ /** OR-3: the commands that own their own stdin reader (a hidden key prompt,
94
+ * an OAuth paste prompt) or read nothing at all — never the editor.
95
+ * OR-9 adds `update`: npm inherits the terminal for the install's own
96
+ * progress and prompts, so no editor may hold stdin while it runs. */
97
+ const CREDENTIAL_COMMANDS = new Set(["login", "logout", "auth", "update"]);
98
+ /** OR-9: the one install command the README gives, which `kiso update`
99
+ * runs and the failure message names. */
100
+ const INSTALL_COMMAND = ["npm", "i", "-g", "@vincemakes/kiso-code@latest"];
65
101
  function taskFileInput(content) {
66
102
  const base = readlineInput(createInterface({ input: Readable.from([]), output: process.stdout }));
67
103
  return {
@@ -102,6 +138,17 @@ function readlineInput(rl) {
102
138
  /* readline has no ctrl+o binding — ignored (W15 rides the
103
139
  * editor path only). */
104
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
+ },
105
152
  onCopy() {
106
153
  /* readline has no ctrl+x binding — ignored. `/copy` still
107
154
  * works here: it is a typed command, not a key. */
@@ -186,6 +233,15 @@ function editorInput(editor) {
186
233
  onExpand(cb) {
187
234
  editor.onExpand(cb);
188
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
+ },
189
245
  onCopy(cb) {
190
246
  editor.onCopy(cb);
191
247
  },
@@ -343,7 +399,15 @@ function makeLineInput() {
343
399
  // here", which is the argument W6 made for the box and the only part
344
400
  // of it that survives. A prompt character is a third thing saying
345
401
  // the same thing, and it cost the row a column.
346
- 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));
347
411
  // TUI2-R3v2 ②: the click hit-test's wiring — the compositor places
348
412
  // the panel's option rows, so the compositor is what the editor asks
349
413
  // where they are. Neither side computes the other's geometry.
@@ -384,28 +448,42 @@ function bannerExtensionText() {
384
448
  * resize; the resume list re-gates with the tier); off-TTY: the
385
449
  * historical `[N extensions: ...]` standalone line (zero change). */
386
450
  /**
387
- * The update line, when there is one, appended after the opening.
451
+ * The update card, when there is one, appended after the opening.
388
452
  *
389
- * It never blocks: the caller does not await it, and the banner is
390
- * already drawn when this starts. If the check fails, times out, is
391
- * switched off, or finds nothing newer, this does nothing at all and
392
- * says nothing about having tried.
453
+ * Two painters, one card. The boot paints what the CACHE already knows,
454
+ * synchronously, so a known newer version is under the banner on the
455
+ * first frame; the check then runs fired-and-forgotten (at most one
456
+ * request a day) and paints only a version the boot did not. If the check
457
+ * fails, times out, is switched off, or finds nothing newer, it does
458
+ * nothing at all and says nothing about having tried.
393
459
  */
460
+ /** OR-9: the version this process has already carded — the boot's cache
461
+ * paint and the async check's answer never both card one version. */
462
+ let updateShown = null;
463
+ const updateDeps = () => ({ kisoHome: kisoHome(), version: VERSION, isTTY: process.stdout.isTTY === true, faux: currentFaux });
464
+ /** OR-9 (owner, 2026-09-09): the update is a CARD under the banner — a
465
+ * rule, a bold title, the version with the command that installs it, the
466
+ * changelog, a rule — the shape the reference shows at every start. The
467
+ * RAW channel (not `notice`, which strips SGR through escapeTerminal); a
468
+ * raw cell lands at the transcript's end, never spliced mid-frame. A
469
+ * declared exception to R2 law 1.1 (a notice wears no edge): a card is
470
+ * not a notice, and the owner asked for the edges. */
471
+ function paintUpdateCard(latest) {
472
+ if (latest === updateShown)
473
+ return;
474
+ updateShown = latest;
475
+ const p = palette();
476
+ const width = Math.min(process.stdout.columns ?? 80, 80);
477
+ const rule = `${p.dim}${"─".repeat(width)}${p.reset}`;
478
+ const [title, line, changelog] = updateCardLines(latest);
479
+ bodyLog(`${rule}\n${p.bold}${title}${p.reset}\n${line}\n${changelog}\n${rule}`, "words");
480
+ }
394
481
  async function announceUpdate() {
395
482
  try {
396
- const line = await checkForUpdate({ kisoHome: kisoHome(), version: VERSION, isTTY: process.stdout.isTTY === true, faux: currentFaux });
397
- if (line === null)
483
+ const latest = await checkForUpdate(updateDeps());
484
+ if (latest === null)
398
485
  return;
399
- // THE RAW CHANNEL, not `notice`. A notice renders through ErrorLine,
400
- // which calls `escapeTerminal` — so every SGR in it is stripped and
401
- // the line could only ever be full-strength prose. The raw channel
402
- // is the one the banner itself uses: the styling is applied HERE, at
403
- // composition, and the fold keeps the span intact (#16b).
404
- //
405
- // A turn may have started while the request was in flight. A raw
406
- // cell is a cell like any other, so it lands at the transcript's
407
- // end — never spliced into a frame the reader is mid-way through.
408
- bodyLog(`${palette().dim}${line}${palette().reset}`, "words");
486
+ paintUpdateCard(latest);
409
487
  }
410
488
  catch {
411
489
  // belt and braces: checkForUpdate does not throw, and if it ever
@@ -553,6 +631,11 @@ function positiveIntEnv(name) {
553
631
  return Number.isFinite(n) && n > 0 ? n : undefined;
554
632
  }
555
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);
556
639
  // E3: the project-level trust gate runs BEFORE any extension load (the
557
640
  // mcp/skills merges must be in the env when the user-level extensions
558
641
  // load). Untrusted project capability is never loaded — never silently.
@@ -614,16 +697,19 @@ async function makeAgent(sessionId, input, modelFlag) {
614
697
  // generated extension into it so a first-time rule joins the chain
615
698
  // at the NEXT run (the run's policies are fixed at its start; run.ts
616
699
  // re-reads the config's extensions array per run).
617
- 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];
618
703
  setCurrentAgentExtensions(extensions);
619
704
  // E6: the run-start context policy (captured once — exactOptionalPropertyTypes).
620
705
  const contextPolicy = contextPolicyFromEnv();
706
+ const idleFromEnv = streamIdleFromEnv(); // read once: a narrowed const, not a call per spread
621
707
  const definition = {
622
708
  model,
623
709
  store,
624
710
  // Area 5: the coding tools are bound to the workspace — every path
625
711
  // they touch is canonicalized inside cwd, escapes are refused.
626
- 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
627
713
  // Modes: the five tiers ride the E1 policy chain (mode:<tier>
628
714
  // extensions, current tier first) — the old static PERMISSION_POLICY
629
715
  // is gone, its semantics live in the "default" tier. The banner
@@ -668,8 +754,13 @@ async function makeAgent(sessionId, input, modelFlag) {
668
754
  ...(sessionId !== undefined ? { promptCacheKey: sessionId } : {}),
669
755
  ...(resolved.profile.baseUrl !== undefined ? { baseUrl: resolved.profile.baseUrl } : {}),
670
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 } : {}),
671
759
  }
672
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 } : {}),
673
764
  };
674
765
  return createAgent(definition);
675
766
  }
@@ -742,7 +833,7 @@ async function pickSession(agent, input) {
742
833
  function paintBootStatus(session) {
743
834
  if (!dock.active)
744
835
  return;
745
- dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), agentModel, displayCtxRatio(session)));
836
+ dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), statusModelLabel(session), displayCtxRatio(session)));
746
837
  }
747
838
  /** PH-1a (finding PH-F12): a usage error raised from inside the TUI —
748
839
  * main's finally still runs (dock teardown, agent close, temp cleanup)
@@ -756,12 +847,137 @@ class CliUsageError extends Error {
756
847
  * line (the previous conversation stays resumable — clear/switch
757
848
  * never erase history). Faux sessions re-arm the scripted adapter at
758
849
  * the NEW session's durable position, exactly like the picker path. */
759
- 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) {
760
967
  let id = firstId;
761
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;
762
975
  for (;;) {
763
976
  const session = await agent.session({ id, ...(acceptDrift() ? { acceptDrift: true } : {}) });
764
- if (prev === null) {
977
+ if (rebuilt) {
978
+ rebuilt = false;
979
+ }
980
+ else if (prev === null) {
765
981
  bodyLog(`session ${id}\n`);
766
982
  // REL-0152-D5: a session with history says what that history WAS.
767
983
  // Resuming used to print this one line and drop you at an empty
@@ -778,11 +994,16 @@ async function chatLoop(agent, firstId, input, autoCompact) {
778
994
  // throws on profile drift — so one drifted session anywhere in the
779
995
  // history stopped kiso from starting at all.
780
996
  extensionsBanner();
781
- // The one line the opening may gain (§7.10's ruling): "there is
782
- // a newer kiso". FIRED AND FORGOTTEN — the banner above is
783
- // already on screen and this never delays it. When the answer
784
- // arrives it rides `body.notice`, the commit path notices
785
- // already use, rather than a second channel into the frame.
997
+ // OR-9 (owner, 2026-09-09): the update card under the banner, at
998
+ // EVERY start while a newer version is known — §7.10's once-only
999
+ // line is superseded. What the cache knows is painted NOW, with
1000
+ // no request, so the card is part of the opening rather than a
1001
+ // late arrival; the check itself stays FIRED AND FORGOTTEN — the
1002
+ // banner is already on screen and it never delays it — and it
1003
+ // paints only a version the boot did not know.
1004
+ const known = knownUpdate(updateDeps());
1005
+ if (known !== null)
1006
+ paintUpdateCard(known);
786
1007
  void announceUpdate();
787
1008
  }
788
1009
  else {
@@ -811,6 +1032,11 @@ async function chatLoop(agent, firstId, input, autoCompact) {
811
1032
  const end = await chat(session, currentFaux, input, autoCompact, nav);
812
1033
  if (end.next === "exit")
813
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
+ }
814
1040
  prev = id;
815
1041
  id = end.id;
816
1042
  }
@@ -830,10 +1056,15 @@ async function readSecret(prompt) {
830
1056
  process.stdin.resume();
831
1057
  });
832
1058
  }
833
- process.stdout.write(prompt);
834
1059
  return await new Promise((resolve) => {
835
1060
  let buf = "";
1061
+ // OR-3: raw mode BEFORE the prompt — the hidden prompt must be hidden
1062
+ // before it is shown. Written the other way round, a key that arrives
1063
+ // between the prompt and setRawMode is echoed by the tty line
1064
+ // discipline (the auth-tty gate caught it: a driver that types the
1065
+ // instant the prompt appears saw the key on screen two runs in three).
836
1066
  process.stdin.setRawMode(true);
1067
+ process.stdout.write(prompt);
837
1068
  process.stdin.resume();
838
1069
  process.stdin.setEncoding("utf8");
839
1070
  const onData = (chunk) => {
@@ -975,7 +1206,20 @@ async function main() {
975
1206
  // (entered here, dock-bound, trusted before any extension loads),
976
1207
  // readline elsewhere. The trust question, chat, and resume all read
977
1208
  // through it; main's finally closes it on every exit path.
978
- const input = taskFile !== undefined ? taskFileInput(taskFile) : makeLineInput();
1209
+ // OR-3 (owner, 2026-09-09): the credential commands never read the shared
1210
+ // line input, and on a TTY makeLineInput() enters the raw-mode editor and
1211
+ // sends the ground probe (CSI ?996n + OSC 11) BEFORE the command switch.
1212
+ // `kiso login chatgpt` then had two readers on stdin: the editor swallowed
1213
+ // the keystrokes, the terminal's OSC reply was typed into the login's own
1214
+ // readline, the "open this URL" line vanished under the dock's repaint,
1215
+ // and the sign-in never completed. These commands get an input over an
1216
+ // EMPTY readable instead — no editor, no probe, stdin untouched — so the
1217
+ // hidden key prompt and the OAuth paste prompt are the only readers.
1218
+ const input = taskFile !== undefined
1219
+ ? taskFileInput(taskFile)
1220
+ : CREDENTIAL_COMMANDS.has(command ?? "")
1221
+ ? readlineInput(createInterface({ input: Readable.from([]), output: process.stdout }))
1222
+ : makeLineInput();
979
1223
  // R3a — cross-session input history: ~/.kiso/history, one line per
980
1224
  // entry, appended on submit, tail-500 at load (truncated by REWRITE
981
1225
  // at startup so the file never grows unbounded). Unreadable file =
@@ -1195,6 +1439,9 @@ async function main() {
1195
1439
  const cred = await chatgptFlow.login({
1196
1440
  notify: (line) => process.stdout.write(`${line}\n`),
1197
1441
  prompt: (q) => new Promise((resolve) => rl.question(q, resolve)),
1442
+ // OR-4: a person at a terminal gets the browser opened for
1443
+ // them; a pipe, a test rig or KISO_NO_BROWSER=1 gets the URL only.
1444
+ ...(process.stdout.isTTY === true && process.env.KISO_NO_BROWSER === undefined ? { open: openInBrowser } : {}),
1198
1445
  ...(cbPort !== undefined ? { callback: { host: process.env.KISO_OAUTH_CALLBACK_HOST ?? "127.0.0.1", port: cbPort } } : {}),
1199
1446
  ...(process.env.KISO_OAUTH_TOKEN_URL !== undefined ? { tokenUrl: process.env.KISO_OAUTH_TOKEN_URL } : {}),
1200
1447
  });
@@ -1213,6 +1460,27 @@ async function main() {
1213
1460
  process.stdout.write(`signed in to ${provider} with an API key (${maskSecret(key)}) — stored in ${authPath()}\n`);
1214
1461
  break;
1215
1462
  }
1463
+ case "update": {
1464
+ // OR-9: the command the update card names. A thin hand-off to
1465
+ // the ONE install command the README gives — npm resolved from
1466
+ // PATH, its stdio inherited so its progress, its prompts and
1467
+ // its own errors are what the person sees, its exit code the
1468
+ // outcome. kiso adds nothing to what npm knows: no version
1469
+ // pinning, no registry, no elevation. A failure (a global
1470
+ // prefix this user cannot write, a network that is down) is
1471
+ // npm's to explain; the message names the manual command so
1472
+ // the person can run it with whatever their setup needs.
1473
+ const [bin, ...installArgs] = INSTALL_COMMAND;
1474
+ const r = spawnSync(bin, installArgs, { stdio: "inherit" });
1475
+ if (r.error !== undefined || r.status !== 0) {
1476
+ const why = r.error !== undefined ? `could not run npm (${r.error.message})` : `npm exited ${r.status ?? "on a signal"}`;
1477
+ process.stderr.write(`kiso update: ${why} — run it yourself: ${INSTALL_COMMAND.join(" ")}\n`);
1478
+ process.exitCode = r.status ?? 1;
1479
+ break;
1480
+ }
1481
+ process.stdout.write("kiso updated — the next `kiso` runs the new version\n");
1482
+ break;
1483
+ }
1216
1484
  case "help": {
1217
1485
  // PH-1b (finding PH-F21): the CLI must be able to describe its
1218
1486
  // own configuration — the old help listed five commands and
@@ -1230,6 +1498,7 @@ async function main() {
1230
1498
  " chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
1231
1499
  " kiso logout <provider> remove the stored credential\n" +
1232
1500
  " kiso auth list stored credentials (keys masked)\n" +
1501
+ " kiso update install the latest release (npm i -g @vincemakes/kiso-code@latest)\n" +
1233
1502
  " kiso help this help\n\n" +
1234
1503
  "flags (any position):\n" +
1235
1504
  " --model <profile|provider/model> pick the model (also /model in-session)\n" +
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;