@vincemakes/kiso-code 0.39.2 → 0.40.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/dispatch.js CHANGED
@@ -3,17 +3,37 @@
3
3
  * turns. The bodies moved verbatim from chat()'s closure; chat provides
4
4
  * the context (the chain, the run state, the prompt arming).
5
5
  */
6
- import { contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, palette, renderEvent, settledLabel, slashCommandNames } from "@vincemakes/kiso-tui";
6
+ import { STATUS_GLYPHS, contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, compactingStatus, palette, renderEvent, settledLabel, slashCommandNames } from "@vincemakes/kiso-tui";
7
7
  import { newSessionId } from "./session-id.js";
8
8
  import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
9
- import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
9
+ import { MODES, MODE_NOTE, OFFERED_MODES, getMode, setMode } from "./mode.js";
10
10
  import { clipboardWrite, lastAnswer } from "./clipboard.js";
11
- import { agentModel, body, bodyLog, codingToolOptions, kisoHome, configModels, dock, lastBinding, mergedConfig, readContextLedger, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setLastBinding } from "./state.js";
11
+ import { protectedBangReason, protectedShellVerdict } from "./protected-shell.js";
12
+ import { agentModel, body, bodyLog, codingToolOptions, protectedFiles, kisoHome, configModels, dock, lastBinding, loadedSkillsCatalog, mergedConfig, readContextLedger, retryOnRow, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setRetryShown, setLastBinding } from "./state.js";
12
13
  import { adapterOptionsFor } from "./auth/adapter-options.js";
13
- import { microcompactThresholdFor } from "./chat.js";
14
+ import { contextWindowTokens, microcompactThresholdFor, startStatusSpinner } from "./chat.js";
14
15
  import { authForProfile, directWriteProfile, profileAvailable, resolveContextWindow, unavailableReason } from "./config.js";
15
16
  import { shellTool } from "@vincemakes/kiso-tools-node";
16
- import { join } from "node:path";
17
+ import { dirname, join } from "node:path";
18
+ import { realpathSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { resolveSkillLine, skillsRows } from "./skill-invoke.js";
21
+ /** Where a skill directory really lives. Project and user skills are
22
+ * merged into one scan directory by symlink, so the scan root names a
23
+ * temp directory; the link's target is the answer a person can use. */
24
+ function skillSource(dir) {
25
+ const scan = process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
26
+ try {
27
+ return tildePath(dirname(realpathSync(join(scan, dir))));
28
+ }
29
+ catch {
30
+ return tildePath(scan); // a broken link has no target to name
31
+ }
32
+ }
33
+ function tildePath(p) {
34
+ const home = homedir();
35
+ return p === home || p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
36
+ }
17
37
  /** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
18
38
  * and its default, from the registry, shown wherever the profile is listed
19
39
  * — kiso never silently drops a level, and now it shows the ones it takes.
@@ -130,9 +150,21 @@ export function parseBang(trimmed) {
130
150
  * No approval panel, at any tier including plan: the human typed the
131
151
  * command. That is not a policy exemption, it is structural — this
132
152
  * dispatcher only ever sees lines a human submitted, so a model cannot
133
- * reach this branch. */
153
+ * reach this branch.
154
+ *
155
+ * One check it does take: kiso never serves its own credential store to a
156
+ * model, and `!cmd` hands its output to one. `!!cmd` does not — but the
157
+ * two are one keystroke apart, so a line naming a protected file runs
158
+ * under neither, and the store stays one `cat` away in the person's own
159
+ * terminal. */
134
160
  function runBang(command, send, ctx) {
135
161
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
162
+ const guard = protectedShellVerdict(command, codingToolOptions().workspaceRoot, protectedFiles(), { home: homedir(), kisoHome: kisoHome() });
163
+ if (guard.refused) {
164
+ bodyLog(protectedBangReason(guard));
165
+ ctx.input.prompt();
166
+ return;
167
+ }
136
168
  const controller = new AbortController();
137
169
  activeBang = controller;
138
170
  try {
@@ -469,7 +501,7 @@ export function dispatch(line, ctx) {
469
501
  const picked = await new Promise((resolve) => {
470
502
  ctx.input.panelAsk(modePickView({
471
503
  header: `mode — current: ${current}`,
472
- options: MODES.map((name) => ({ label: name, note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
504
+ options: OFFERED_MODES.map((name) => ({ label: name, note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
473
505
  },
474
506
  // DC-12 (design §4): a panel WAITING ON A HUMAN says ❯.
475
507
  ctx.isRunning() ? "❯ run paused" : `▸ ${current}`), (v) => resolve(v.action === "picked" ? v.result : null));
@@ -479,10 +511,10 @@ export function dispatch(line, ctx) {
479
511
  ctx.input.prompt();
480
512
  return; // esc — nothing switched, nothing said
481
513
  }
482
- const chosen = "index" in picked ? MODES[picked.index] : MODES.find((x) => x === picked.custom.trim());
514
+ const chosen = "index" in picked ? OFFERED_MODES[picked.index] : MODES.find((x) => x === picked.custom.trim());
483
515
  if (chosen === undefined) {
484
516
  bodyLog(`no such mode: ${"index" in picked ? String(picked.index) : picked.custom.trim()}`);
485
- bodyLog(`tiers: ${MODES.join(" ")}`);
517
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
486
518
  }
487
519
  else {
488
520
  setMode(chosen);
@@ -493,11 +525,11 @@ export function dispatch(line, ctx) {
493
525
  return;
494
526
  }
495
527
  bodyLog(`mode ${getMode()}`);
496
- bodyLog(`tiers: ${MODES.join(" ")}`);
528
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
497
529
  }
498
530
  else if (m === undefined) {
499
531
  bodyLog(`no such mode: ${trimmed.slice(5).trim()}`);
500
- bodyLog(`tiers: ${MODES.join(" ")}`);
532
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
501
533
  }
502
534
  else {
503
535
  setMode(m);
@@ -666,11 +698,16 @@ export function dispatch(line, ctx) {
666
698
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
667
699
  }),
668
700
  },
701
+ // A1b: the in-run tiers are drawn from the same window.
702
+ contextWindow: contextWindowTokens({ model: profile.model, ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}) }),
669
703
  // OR-1: the endpoint is the fourth passenger — the cost
670
704
  // path and the window lookup key on (model, endpoint).
671
705
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
672
706
  ...(scope !== undefined ? { scope } : {}),
673
707
  ...(reasoning !== undefined ? { reasoning } : {}),
708
+ // 0.40.0: the NAME the person switched to, recorded for
709
+ // display — a direct provider/model has none.
710
+ profileName: direct === null ? profName : null,
674
711
  };
675
712
  ctx.session.setModelBinding(binding);
676
713
  setLastBinding(binding);
@@ -732,7 +769,12 @@ export function dispatch(line, ctx) {
732
769
  };
733
770
  ctx.input.onEscape(onEscape);
734
771
  let compactStart = 0;
735
- let compactTimer = null;
772
+ let repaintCompacting = () => { };
773
+ // 0.40.0 (the owner's dogfood): the row walks the WORKING twinkle,
774
+ // the same spinner as a running turn — a static glyph repainted
775
+ // once a second read as a stalled screen while the model thought
776
+ let glyph = STATUS_GLYPHS[0];
777
+ let stopSpinner = null;
736
778
  // the `as` on the initializer keeps the flow type the full union —
737
779
  // onStart fills this during the call, but a closure assignment
738
780
  // never re-narrows the outer scope (it would read `never`)
@@ -740,20 +782,30 @@ export function dispatch(line, ctx) {
740
782
  // the ctx estimate BEFORE the summarized event lands (the used
741
783
  // fraction — the recap's "ctx 91% → 34%" drops after compacting)
742
784
  let ctxBefore = null;
785
+ // 0.40.0: the summary call's progress against its output budget —
786
+ // the latest report, read by the row; the runtime reports per
787
+ // streamed chunk and the row repaints at most every 200 ms
788
+ let progress = null;
789
+ let lastProgressPaint = 0;
743
790
  const compacting = (info) => {
744
- const text = (elapsed) => `▘ compacting · ${info.rounds} rounds · ~${kUnit(info.tokens)} tokens · ${Math.max(0, elapsed)}s`;
791
+ // 0.40.0: composed by the row seam, not a template here — the row
792
+ // is where the launch build's progress segment plugs in.
793
+ const text = (elapsed) => compactingStatus(glyph, info.rounds, info.tokens, elapsed, undefined, retryOnRow(), progress);
794
+ repaintCompacting = () => dock.setStatus(text(Math.round((Date.now() - compactStart) / 1000)), "esc to cancel");
745
795
  compactStart = Date.now();
746
796
  ctxBefore = ctxPercent(ctx.estimateCtx());
747
797
  dock.setStatus(text(0), "esc to cancel");
748
- compactTimer = setInterval(() => {
749
- dock.setStatus(text(Math.round((Date.now() - compactStart) / 1000)), "esc to cancel");
750
- }, 1000);
798
+ stopSpinner = startStatusSpinner((next) => {
799
+ glyph = next;
800
+ repaintCompacting();
801
+ });
751
802
  };
752
803
  try {
753
804
  // R3a: /compact <focus> — the words after the command steer
754
805
  // the summary ("keep the auth details"); bare /compact is
755
806
  // byte-identical to the pre-round call.
756
807
  const focus = trimmed.slice(8).trim();
808
+ setRetryShown(null);
757
809
  const result = await ctx.session.summarize({
758
810
  signal: abort.signal,
759
811
  // 0.39.2: the gesture gets the measured output budget and
@@ -767,6 +819,14 @@ export function dispatch(line, ctx) {
767
819
  compactInfo = info;
768
820
  compacting(info);
769
821
  },
822
+ onProgress: (p) => {
823
+ progress = { produced: p.produced, budget: p.budget, reasoningUnseen: p.reasoningUnseen };
824
+ const now = Date.now();
825
+ if (now - lastProgressPaint < 200)
826
+ return;
827
+ lastProgressPaint = now;
828
+ repaintCompacting();
829
+ },
770
830
  });
771
831
  if (result === null) {
772
832
  body.notice("[/compact] nothing to compact — fewer than 5 rounds yet");
@@ -828,8 +888,10 @@ export function dispatch(line, ctx) {
828
888
  }
829
889
  }
830
890
  finally {
831
- if (compactTimer !== null)
832
- clearInterval(compactTimer);
891
+ stopSpinner?.();
892
+ // ADR-0005 Amendment 2: a retry belongs to the call that
893
+ // announced it — none may outlive the compaction into the idle row.
894
+ setRetryShown(null);
833
895
  ctx.paintIdle();
834
896
  }
835
897
  ctx.input.prompt();
@@ -910,8 +972,15 @@ export function dispatch(line, ctx) {
910
972
  // would; the validation is the difference
911
973
  if (!ctx.sessions().includes(arg)) {
912
974
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
913
- bodyLog(`no such session: ${escapeTerminal(arg)} — /resume lists them`);
914
- ctx.input.prompt();
975
+ const route = ctx.route?.(arg) ?? null;
976
+ if (route === null || route.kind === "refused") {
977
+ bodyLog(route?.line ?? `no such session: ${escapeTerminal(arg)} — /resume lists them`);
978
+ ctx.input.prompt();
979
+ return;
980
+ }
981
+ if (route.kind === "elsewhere" && route.line !== null)
982
+ bodyLog(route.line);
983
+ ctx.requestSwitch(arg);
915
984
  });
916
985
  return;
917
986
  }
@@ -933,6 +1002,16 @@ export function dispatch(line, ctx) {
933
1002
  ctx.input.prompt(); // esc — nothing switched, nothing said
934
1003
  return;
935
1004
  }
1005
+ // 0.40.0: `tab` lists every project; another project's recorded
1006
+ // session is refused with where to go
1007
+ const route = ctx.sessions().includes(picked) ? null : (ctx.route?.(picked) ?? null);
1008
+ if (route?.kind === "refused") {
1009
+ bodyLog(route.line);
1010
+ ctx.input.prompt();
1011
+ return;
1012
+ }
1013
+ if (route?.kind === "elsewhere" && route.line !== null)
1014
+ bodyLog(route.line);
936
1015
  ctx.requestSwitch(picked);
937
1016
  });
938
1017
  return;
@@ -945,6 +1024,25 @@ export function dispatch(line, ctx) {
945
1024
  });
946
1025
  return;
947
1026
  }
1027
+ // 0.40.0 — a person's skill: `/skills`, `/skill <name> [args]`, or
1028
+ // `/<name> [args]` for a name no built-in above claimed. Placed AFTER
1029
+ // every built-in so a built-in always wins, and BEFORE the unknown-
1030
+ // command line so an installed skill is never called unknown.
1031
+ const skill = resolveSkillLine(trimmed, loadedSkillsCatalog(), slashCommandNames());
1032
+ if (trimmed === "/skills" || trimmed === "/skill" || skill !== null) {
1033
+ if (skill?.kind === "submit") {
1034
+ ctx.submitTurn(skill.content, skill.via);
1035
+ return;
1036
+ }
1037
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
1038
+ if (skill?.kind === "list")
1039
+ bodyLog(skillsRows(loadedSkillsCatalog(), skillSource, tildePath(join(kisoHome(), "skills"))).map(escapeTerminal).join("\n"));
1040
+ else if (skill?.kind === "error")
1041
+ bodyLog(escapeTerminal(skill.message));
1042
+ ctx.input.prompt();
1043
+ });
1044
+ return;
1045
+ }
948
1046
  // PH-1a (finding PH-F1): an unrecognized slash command is an ERROR,
949
1047
  // never a turn — the fallthrough used to hand "/clear", "/exit", or a
950
1048
  // typo to the model, burning a request on text the user meant as a
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 0.40.0 — the catastrophe floor (launch-weekend plan §4, minimum shape;
3
+ * owner, 2026-09-17: "bypass stays bypass: `rm -rf /tmp/probe` runs. The
4
+ * floor refuses only unrecoverable targets"; the lead's review and rulings
5
+ * of 2026-09-18).
6
+ *
7
+ * A destructive command whose target cannot be recovered is REFUSED, in
8
+ * every mode, bypass included, as a precondition — a chain member that
9
+ * denies, and a deny is what nothing outranks. The destructive commands:
10
+ * `rm`; `git clean -f`, `git reset --hard`, `git checkout -- <paths>` and
11
+ * the same loss spelled `git checkout .` / `-f`, `git restore` touching
12
+ * the worktree, `git switch -f` (R5); `find … -delete` with no selecting
13
+ * primary before it (R6). The unrecoverable targets:
14
+ *
15
+ * - `/`, the home directory, the workspace root or any directory above it;
16
+ * - the workspace's `.git` (R7);
17
+ * - `~/.ssh`, `~/.config`, `~/.kiso`, `~/.gnupg`, `~/.aws`, or inside one;
18
+ * - a system root and what is inside it — except the temp family and the
19
+ * software prefixes (/opt/homebrew, /usr/local, linuxbrew), where only
20
+ * the root itself is, and /Volumes /Users /home /mnt /media, where the
21
+ * root and its direct children are (R8). Inside the workspace or the
22
+ * home directory is never a system root's;
23
+ * - a wildcard over any of those (`~/*`, `/*`, `*` at the workspace root —
24
+ * a component of nothing but globs; `*.log` ranges over SOME entries);
25
+ * - a target that is only a variable (`$DIR/`, `"$X"/*`) — empty, it is
26
+ * the root of whatever comes after it. `$HOME` and `$PWD` are known.
27
+ *
28
+ * This is a reading of the command line, not a guarantee: the sandbox is
29
+ * the guarantee, and it comes after the launch. So the reading errs toward
30
+ * seeing a command — it looks inside `$( )`, backticks, `( )`, `sh -c` and
31
+ * `eval`, through `sudo`/`env`/`timeout`/`command` and shell keywords, and
32
+ * follows `cd` by its joiner — and a line that knocks the reader out is
33
+ * denied. Documented, not refused: `xargs rm`, script files and heredocs,
34
+ * `find -exec rm`, `mv`, `chmod -R`, `rmdir`, `unlink`, `truncate`,
35
+ * `shred`, `>` truncation, `rsync --delete`, `npx rimraf`, `trash`,
36
+ * `fish -c`, `ssh`, `docker`, `node -e` / `python -c` / `perl -e`, brace
37
+ * expansion, `$'…'`, `~+`, popd past one level, nesting past 16.
38
+ *
39
+ * The same resolver as the read-only allow decides where a path goes.
40
+ * Default on; `"floor": "off"` in the USER config turns it off, and a
41
+ * project config cannot — a repository must not be able to lower it.
42
+ */
43
+ import type { PolicyCall } from "@vincemakes/kiso-core";
44
+ import type { KisoExtension } from "@vincemakes/kiso-runtime";
45
+ import { type LooseWord } from "./shell-words.js";
46
+ export type FloorVerdict = {
47
+ readonly refused: false;
48
+ } | {
49
+ readonly refused: true;
50
+ readonly why: string;
51
+ };
52
+ /** Past keywords, `sudo -u x`, `env A=1`, `timeout 5`, `command`, … to the
53
+ * command that runs. Matched by basename: `/usr/bin/env rm` is env. */
54
+ export declare function unwrap(argv: readonly LooseWord[]): readonly LooseWord[];
55
+ /** `sh -c '…'`, `bash -lc '…'`, `eval …`: the command lines inside.
56
+ * eval CONCATENATES its words with spaces and parses the result again —
57
+ * that is its meaning, and `eval 'rm -rf ~'` is read that way. It is also
58
+ * read once more with each word re-quoted, so `eval sh -c 'rm -rf /'`
59
+ * keeps its script whole (B10, the lead's reading): either refusing is a
60
+ * refusal. In the re-quoted reading `~` is already expanded and a word
61
+ * holding an unknown stays raw — its value is unknown to eval too. */
62
+ export declare function innerLines(argv: readonly LooseWord[], home: string): {
63
+ readonly lines: readonly string[];
64
+ readonly subshell: boolean;
65
+ } | null;
66
+ /**
67
+ * Would the floor refuse this command line? `workspaceRoot` is where the
68
+ * shell runs it.
69
+ */
70
+ export declare function floorCheck(commandLine: string, workspaceRoot: string, home?: string): FloorVerdict;
71
+ /** Is there a destructive command anywhere in the line, whatever its
72
+ * target? A saved allow is never inherited by one (plan §4). */
73
+ export declare function isDestructive(commandLine: string): boolean;
74
+ /** The chain member, at the chain's head: DENY or ABSTAIN. */
75
+ export declare function floorExtension(on: () => boolean, workspaceRoot: () => string, check?: typeof floorCheck): KisoExtension;
76
+ /** Plan §4: "destructive commands never inherit a saved allow in the
77
+ * asking modes" — the predicate the saved-allow wrap
78
+ * (protected-writes.ts) is given, keyed on destructiveness, not on the
79
+ * target: a saved allow never runs even `rm -rf build` unasked. */
80
+ export declare function isDestructiveCall(call: PolicyCall): boolean;