@vincemakes/kiso-code 0.39.2 → 0.40.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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/attachments.d.ts +1 -1
  3. package/dist/attachments.js +11 -4
  4. package/dist/builtin.d.ts +12 -0
  5. package/dist/builtin.js +22 -1
  6. package/dist/chat.d.ts +31 -3
  7. package/dist/chat.js +169 -30
  8. package/dist/config.d.ts +11 -1
  9. package/dist/config.js +34 -3
  10. package/dist/dispatch.d.ts +12 -2
  11. package/dist/dispatch.js +165 -24
  12. package/dist/floor.d.ts +80 -0
  13. package/dist/floor.js +652 -0
  14. package/dist/index.d.ts +21 -2
  15. package/dist/index.js +463 -46
  16. package/dist/mode.d.ts +12 -4
  17. package/dist/mode.js +32 -10
  18. package/dist/projects.d.ts +106 -0
  19. package/dist/projects.js +212 -0
  20. package/dist/protected-shell.d.ts +88 -0
  21. package/dist/protected-shell.js +406 -0
  22. package/dist/protected-writes.d.ts +29 -0
  23. package/dist/protected-writes.js +54 -0
  24. package/dist/provider-label.d.ts +32 -0
  25. package/dist/provider-label.js +53 -0
  26. package/dist/readonly-shell.d.ts +58 -0
  27. package/dist/readonly-shell.js +590 -0
  28. package/dist/replay.d.ts +53 -0
  29. package/dist/replay.js +158 -0
  30. package/dist/resume-tail.js +4 -1
  31. package/dist/resume.js +3 -3
  32. package/dist/retries.d.ts +9 -0
  33. package/dist/retries.js +16 -0
  34. package/dist/session-cards.d.ts +33 -4
  35. package/dist/session-cards.js +44 -3
  36. package/dist/session-migration.d.ts +104 -0
  37. package/dist/session-migration.js +319 -0
  38. package/dist/shell-words.d.ts +134 -0
  39. package/dist/shell-words.js +628 -0
  40. package/dist/skill-invoke.d.ts +58 -0
  41. package/dist/skill-invoke.js +139 -0
  42. package/dist/state.d.ts +80 -1
  43. package/dist/state.js +113 -2
  44. package/dist/trust-ui.js +29 -5
  45. package/package.json +15 -15
package/dist/config.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * "anthropic" | "openai-responses")
13
13
  * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
14
14
  * baseUrl?: string, model: string, apiKeyEnv: string } }
15
- * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
+ * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"|"dontAsk"
16
16
  * contextWindow?: number — tokens
17
17
  * autoCompact?: { thresholdRatio: number } — 0<r<1; default off
18
18
  * projectTrust?: "ask" | "never" — no "always" (the ruling)
@@ -27,13 +27,14 @@
27
27
  * with an invalid value is likewise loud; unknown top-level keys are
28
28
  * ignored (forward compatibility).
29
29
  */
30
- import { readFileSync } from "node:fs";
30
+ import { readFileSync, statSync } from "node:fs";
31
+ import { homedir } from "node:os";
31
32
  import { join } from "node:path";
32
33
  import { kisoHome } from "./state.js";
33
34
  import { AuthError, getCredential, providerIdOf } from "./auth/credentials.js";
34
35
  import { BUILTIN_MANIFESTS } from "@vincemakes/kiso-runtime/internal";
35
36
  const KINDS = ["openai-compat", "anthropic", "openai-responses"];
36
- const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass"];
37
+ const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass", "dontAsk"];
37
38
  export class ConfigError extends Error {
38
39
  }
39
40
  /** Parse + validate one config file. Broken JSON / invalid known values →
@@ -66,6 +67,36 @@ export function parseConfig(text, source) {
66
67
  fail("theme", "belongs in the USER config — a terminal is a property of the person at it, not of the project");
67
68
  out.theme = obj.theme;
68
69
  }
70
+ if (obj.floor !== undefined) {
71
+ if (obj.floor !== "catastrophe" && obj.floor !== "off")
72
+ fail("floor", 'expected "catastrophe" or "off"');
73
+ if (source.startsWith("<cwd>"))
74
+ fail("floor", "belongs in the USER config — a project must never be able to lower the floor");
75
+ out.floor = obj.floor;
76
+ }
77
+ if (obj.protectedPaths !== undefined) {
78
+ if (source.startsWith("<cwd>"))
79
+ fail("protectedPaths", "belongs in the USER config — a project must never be able to change what kiso guards");
80
+ if (!Array.isArray(obj.protectedPaths))
81
+ fail("protectedPaths", "expected an array of paths");
82
+ for (const [i, p] of obj.protectedPaths.entries()) {
83
+ // a relative path would be read against whatever directory kiso
84
+ // runs in — a different file in every project
85
+ if (typeof p !== "string" || !(p.startsWith("/") || p.startsWith("~/")))
86
+ fail(`protectedPaths[${i}]`, "expected an absolute path or one starting with ~/");
87
+ // kiso protects FILES. A directory listed here would protect
88
+ // nothing under it — a security setting that silently does
89
+ // nothing — so it is refused where the person can see it.
90
+ // (Read at every agent build: a directory created later is
91
+ // caught at the next start or /reload.)
92
+ const path = p;
93
+ const full = path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
94
+ if (path.endsWith("/") || statSync(full, { throwIfNoEntry: false })?.isDirectory() === true) {
95
+ fail(`protectedPaths[${i}]`, `${path} is a directory — kiso protects files, so list the files in it (for ~/.aws: "~/.aws/credentials", "~/.aws/config")`);
96
+ }
97
+ }
98
+ out.protectedPaths = obj.protectedPaths;
99
+ }
69
100
  if (obj.checks !== undefined) {
70
101
  if (obj.checks === null || typeof obj.checks !== "object" || Array.isArray(obj.checks))
71
102
  fail("checks", "expected an object of name → command");
@@ -3,8 +3,10 @@
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 type { SessionRoute } from "./projects.js";
6
7
  import type { AgentSession } from "@vincemakes/kiso-runtime";
7
8
  import { type LineInput } from "./state.js";
9
+ import type { UserInputVia } from "@vincemakes/kiso-core";
8
10
  /** Everything dispatch touches that chat() owns. */
9
11
  export interface DispatchCtx {
10
12
  readonly session: AgentSession;
@@ -23,8 +25,10 @@ export interface DispatchCtx {
23
25
  * gone; a binding that has not run yet paints none — an unmeasured
24
26
  * cache is not a 0% cache, and it is not the previous model's either. */
25
27
  readonly modelSwitched: () => void;
26
- /** submit a real turn: queue + chain (the turn closure lives in chat). */
27
- readonly submitTurn: (line: string) => void;
28
+ /** submit a real turn: queue + chain (the turn closure lives in chat).
29
+ * 0.40.0: `via` marks a skill turn — `line` is then the composed
30
+ * content, and the chip shows `via.line`. */
31
+ readonly submitTurn: (line: string, via?: UserInputVia) => void;
28
32
  /** the /status context estimate. */
29
33
  readonly estimateCtx: () => number;
30
34
  /** TUI2-R1 (E): the model's context window, as the session is
@@ -33,11 +37,17 @@ export interface DispatchCtx {
33
37
  /** the /resume+/clear mini-spec: end this chat() with a switch to
34
38
  * another session — main re-enters chat there; the editor survives. */
35
39
  readonly requestSwitch: (id: string) => void;
40
+ /** DC-57: true once this entry has been asked to leave — the guard that keeps a departing session from answering a batch's later lines. */
41
+ readonly leaving: () => boolean;
36
42
  /** §2.5: end this chat() with a REBUILD of the agent on the SAME
37
43
  * session — extensions, skills and config are read again. */
38
44
  readonly requestReload: () => void;
39
45
  /** every durable session id (the /resume validation + listing). */
40
46
  readonly sessions: () => readonly string[];
47
+ /** 0.40.0: an id outside this project's folder — refused with where to
48
+ * go, or opened where it is with a line (absent: every such id is "no
49
+ * such"). */
50
+ readonly route?: (id: string) => SessionRoute | null;
41
51
  /** the dock's session picker, when one exists (bare /resume). */
42
52
  readonly pickSession?: () => Promise<string | null>;
43
53
  }
package/dist/dispatch.js CHANGED
@@ -3,17 +3,39 @@
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 { agentBaseUrl, currentProfileName, setCurrentProfileName, currentModelName, 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 { profileProviderLabel, providerLabel } from "./provider-label.js";
15
+ import { queuedSwitchLines } from "./state.js";
16
+ import { contextWindowTokens, microcompactThresholdFor, startStatusSpinner } from "./chat.js";
14
17
  import { authForProfile, directWriteProfile, profileAvailable, resolveContextWindow, unavailableReason } from "./config.js";
15
18
  import { shellTool } from "@vincemakes/kiso-tools-node";
16
- import { join } from "node:path";
19
+ import { dirname, join } from "node:path";
20
+ import { realpathSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { resolveSkillLine, skillsRows } from "./skill-invoke.js";
23
+ /** Where a skill directory really lives. Project and user skills are
24
+ * merged into one scan directory by symlink, so the scan root names a
25
+ * temp directory; the link's target is the answer a person can use. */
26
+ function skillSource(dir) {
27
+ const scan = process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
28
+ try {
29
+ return tildePath(dirname(realpathSync(join(scan, dir))));
30
+ }
31
+ catch {
32
+ return tildePath(scan); // a broken link has no target to name
33
+ }
34
+ }
35
+ function tildePath(p) {
36
+ const home = homedir();
37
+ return p === home || p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
38
+ }
17
39
  /** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
18
40
  * and its default, from the registry, shown wherever the profile is listed
19
41
  * — kiso never silently drops a level, and now it shows the ones it takes.
@@ -130,9 +152,21 @@ export function parseBang(trimmed) {
130
152
  * No approval panel, at any tier including plan: the human typed the
131
153
  * command. That is not a policy exemption, it is structural — this
132
154
  * dispatcher only ever sees lines a human submitted, so a model cannot
133
- * reach this branch. */
155
+ * reach this branch.
156
+ *
157
+ * One check it does take: kiso never serves its own credential store to a
158
+ * model, and `!cmd` hands its output to one. `!!cmd` does not — but the
159
+ * two are one keystroke apart, so a line naming a protected file runs
160
+ * under neither, and the store stays one `cat` away in the person's own
161
+ * terminal. */
134
162
  function runBang(command, send, ctx) {
135
163
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
164
+ const guard = protectedShellVerdict(command, codingToolOptions().workspaceRoot, protectedFiles(), { home: homedir(), kisoHome: kisoHome() });
165
+ if (guard.refused) {
166
+ bodyLog(protectedBangReason(guard));
167
+ ctx.input.prompt();
168
+ return;
169
+ }
136
170
  const controller = new AbortController();
137
171
  activeBang = controller;
138
172
  try {
@@ -158,6 +192,18 @@ function runBang(command, send, ctx) {
158
192
  });
159
193
  }
160
194
  export function dispatch(line, ctx) {
195
+ // DC-57 (the owner's ruling, 2026-09-21): a session that is LEAVING — a
196
+ // switch or a reload has been requested from an earlier segment of the
197
+ // chain — does not answer new lines. They are queued for the session the
198
+ // person asked for and replayed by its `chat()` entry. The guard lives
199
+ // HERE, not only at the input edge: a batch that arrived before the
200
+ // switch was requested still runs its segments after it (the chain is
201
+ // FIFO), which is exactly how a pasted prompt used to land in the
202
+ // departing session.
203
+ if (ctx.leaving()) {
204
+ queuedSwitchLines.push(line);
205
+ return;
206
+ }
161
207
  const trimmed = line.trim();
162
208
  // §2.2 — the `!` gesture is the COMPOSER's alone. A piped session, `-p`
163
209
  // and `--task-file` keep `!` as ordinary text: those lines are content,
@@ -442,6 +488,21 @@ export function dispatch(line, ctx) {
442
488
  bodyLog(`session ${ctx.session.id}`);
443
489
  bodyLog(`${ctx.session.log.all.length} events`);
444
490
  bodyLog(`ctx ${ctxPct}`);
491
+ // The owner, 2026-09-21: /status is where "what am I actually
492
+ // running on" is answered, so the identity is spelled out HERE —
493
+ // the model, the host the request goes to, the profile it came
494
+ // from and the key it spends. The status row stays short; this
495
+ // line is the disambiguation two same-named profiles need.
496
+ //
497
+ // REVIEW (2026-09-21): the profile is named only when what is in
498
+ // hand IS a profile key. `currentModelName` holds whatever the
499
+ // resolution produced — a profile alias after `/model`, a MODEL ID
500
+ // after a switch or a resume — so printing it unconditionally
501
+ // claimed `profile deepseek-v4-flash` for a session that was
502
+ // restored, not switched. (The real fix is to stop overloading that
503
+ // variable; until then, a label that cannot lie.)
504
+ const profileOf = currentProfileName === null ? "" : ` · profile ${currentProfileName}`;
505
+ bodyLog(`model ${agentModel}${providerLabel(agentBaseUrl)}${profileOf}`);
445
506
  ctx.input.prompt();
446
507
  });
447
508
  return;
@@ -469,7 +530,7 @@ export function dispatch(line, ctx) {
469
530
  const picked = await new Promise((resolve) => {
470
531
  ctx.input.panelAsk(modePickView({
471
532
  header: `mode — current: ${current}`,
472
- options: MODES.map((name) => ({ label: name, note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
533
+ options: OFFERED_MODES.map((name) => ({ label: name, note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
473
534
  },
474
535
  // DC-12 (design §4): a panel WAITING ON A HUMAN says ❯.
475
536
  ctx.isRunning() ? "❯ run paused" : `▸ ${current}`), (v) => resolve(v.action === "picked" ? v.result : null));
@@ -479,10 +540,10 @@ export function dispatch(line, ctx) {
479
540
  ctx.input.prompt();
480
541
  return; // esc — nothing switched, nothing said
481
542
  }
482
- const chosen = "index" in picked ? MODES[picked.index] : MODES.find((x) => x === picked.custom.trim());
543
+ const chosen = "index" in picked ? OFFERED_MODES[picked.index] : MODES.find((x) => x === picked.custom.trim());
483
544
  if (chosen === undefined) {
484
545
  bodyLog(`no such mode: ${"index" in picked ? String(picked.index) : picked.custom.trim()}`);
485
- bodyLog(`tiers: ${MODES.join(" ")}`);
546
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
486
547
  }
487
548
  else {
488
549
  setMode(chosen);
@@ -493,11 +554,11 @@ export function dispatch(line, ctx) {
493
554
  return;
494
555
  }
495
556
  bodyLog(`mode ${getMode()}`);
496
- bodyLog(`tiers: ${MODES.join(" ")}`);
557
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
497
558
  }
498
559
  else if (m === undefined) {
499
560
  bodyLog(`no such mode: ${trimmed.slice(5).trim()}`);
500
- bodyLog(`tiers: ${MODES.join(" ")}`);
561
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
501
562
  }
502
563
  else {
503
564
  setMode(m);
@@ -535,8 +596,20 @@ export function dispatch(line, ctx) {
535
596
  // the note keeps what tells two rows apart; the levels
536
597
  // left it for the axis below (they were being cut off
537
598
  // the end of the note column at 100 columns).
538
- const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : [])];
539
- return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · "), ...effortAxis(profile) };
599
+ // The owner, 2026-09-21: two profiles can name ONE model
600
+ // id and reach two accounts, so the current mark follows
601
+ // the PROFILE the session is on (`currentModelName`), not
602
+ // the model id — the id alone marked both such rows, or
603
+ // neither. The row's own provider rides the label, so two
604
+ // rows that share an id still read differently.
605
+ // 0.40.1 (the owner's dogfood, 2026-09-21): the `profile: <key>` prefix
606
+ // is gone — the row already names the model and its endpoint, and the
607
+ // profile key is what `/model <name>` takes, not what a chooser
608
+ // reading a list needs. What remains is what the chooser CANNOT
609
+ // infer: availability, and which one is live.
610
+ const marks = [...(profileAvailable(profile) ? [] : ["unavailable"]), ...(name === currentProfileName ? ["current"] : [])];
611
+ const host = profileProviderLabel(profile.kind, profile.baseUrl);
612
+ return { label: `${profile.kind}/${profile.model}${host === "" ? "" : ` ${host}`}`, note: marks.join(" · "), ...effortAxis(profile) };
540
613
  }),
541
614
  // PH-1a (finding PH-F4): the example must be a syntax
542
615
  // directWriteProfile actually ACCEPTS — the old
@@ -585,7 +658,8 @@ export function dispatch(line, ctx) {
585
658
  else {
586
659
  for (const name of names) {
587
660
  const p = configModels[name];
588
- bodyLog(` ${name} → ${p.kind}/${p.model} · ${signInNote(p)} ${profileAvailable(p) ? "(available)" : "(unavailable)"} · ${effortNote(p)}`);
661
+ const hostOf = profileProviderLabel(p.kind, p.baseUrl);
662
+ bodyLog(` ${name} → ${p.kind}/${p.model}${hostOf === "" ? "" : ` ${hostOf}`} · ${signInNote(p)} ${profileAvailable(p) ? "(available)" : "(unavailable)"} · ${effortNote(p)}`);
589
663
  }
590
664
  }
591
665
  bodyLog("switch with /model <profile-name|provider/model>");
@@ -666,16 +740,22 @@ export function dispatch(line, ctx) {
666
740
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
667
741
  }),
668
742
  },
743
+ // A1b: the in-run tiers are drawn from the same window.
744
+ contextWindow: contextWindowTokens({ model: profile.model, ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}) }),
669
745
  // OR-1: the endpoint is the fourth passenger — the cost
670
746
  // path and the window lookup key on (model, endpoint).
671
747
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
672
748
  ...(scope !== undefined ? { scope } : {}),
673
749
  ...(reasoning !== undefined ? { reasoning } : {}),
750
+ // 0.40.0: the NAME the person switched to, recorded for
751
+ // display — a direct provider/model has none.
752
+ profileName: direct === null ? profName : null,
674
753
  };
675
754
  ctx.session.setModelBinding(binding);
676
755
  setLastBinding(binding);
677
756
  setAgentModel(profile.model, profile.baseUrl);
678
757
  setCurrentModelName(arg);
758
+ setCurrentProfileName(direct === null ? null : profName);
679
759
  // §2.5: the ONE source a reload reads for the model — a
680
760
  // switch made here must survive the rebuild.
681
761
  setModelChoice(arg);
@@ -695,7 +775,7 @@ export function dispatch(line, ctx) {
695
775
  // (and the owner's) read `gpt-6-astra · CH 92%`, the new
696
776
  // model beside the previous model's figure.
697
777
  ctx.modelSwitched();
698
- body.notice(`model → ${profName} (${profile.model}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
778
+ body.notice(`model → ${profName} (${profile.model}${providerLabel(profile.baseUrl) === "" ? "" : ` ${providerLabel(profile.baseUrl)}`}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
699
779
  }
700
780
  }
701
781
  }
@@ -732,7 +812,12 @@ export function dispatch(line, ctx) {
732
812
  };
733
813
  ctx.input.onEscape(onEscape);
734
814
  let compactStart = 0;
735
- let compactTimer = null;
815
+ let repaintCompacting = () => { };
816
+ // 0.40.0 (the owner's dogfood): the row walks the WORKING twinkle,
817
+ // the same spinner as a running turn — a static glyph repainted
818
+ // once a second read as a stalled screen while the model thought
819
+ let glyph = STATUS_GLYPHS[0];
820
+ let stopSpinner = null;
736
821
  // the `as` on the initializer keeps the flow type the full union —
737
822
  // onStart fills this during the call, but a closure assignment
738
823
  // never re-narrows the outer scope (it would read `never`)
@@ -740,20 +825,30 @@ export function dispatch(line, ctx) {
740
825
  // the ctx estimate BEFORE the summarized event lands (the used
741
826
  // fraction — the recap's "ctx 91% → 34%" drops after compacting)
742
827
  let ctxBefore = null;
828
+ // 0.40.0: the summary call's progress against its output budget —
829
+ // the latest report, read by the row; the runtime reports per
830
+ // streamed chunk and the row repaints at most every 200 ms
831
+ let progress = null;
832
+ let lastProgressPaint = 0;
743
833
  const compacting = (info) => {
744
- const text = (elapsed) => `▘ compacting · ${info.rounds} rounds · ~${kUnit(info.tokens)} tokens · ${Math.max(0, elapsed)}s`;
834
+ // 0.40.0: composed by the row seam, not a template here — the row
835
+ // is where the launch build's progress segment plugs in.
836
+ const text = (elapsed) => compactingStatus(glyph, info.rounds, info.tokens, elapsed, undefined, retryOnRow(), progress);
837
+ repaintCompacting = () => dock.setStatus(text(Math.round((Date.now() - compactStart) / 1000)), "esc to cancel");
745
838
  compactStart = Date.now();
746
839
  ctxBefore = ctxPercent(ctx.estimateCtx());
747
840
  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);
841
+ stopSpinner = startStatusSpinner((next) => {
842
+ glyph = next;
843
+ repaintCompacting();
844
+ });
751
845
  };
752
846
  try {
753
847
  // R3a: /compact <focus> — the words after the command steer
754
848
  // the summary ("keep the auth details"); bare /compact is
755
849
  // byte-identical to the pre-round call.
756
850
  const focus = trimmed.slice(8).trim();
851
+ setRetryShown(null);
757
852
  const result = await ctx.session.summarize({
758
853
  signal: abort.signal,
759
854
  // 0.39.2: the gesture gets the measured output budget and
@@ -767,6 +862,14 @@ export function dispatch(line, ctx) {
767
862
  compactInfo = info;
768
863
  compacting(info);
769
864
  },
865
+ onProgress: (p) => {
866
+ progress = { produced: p.produced, budget: p.budget, reasoningUnseen: p.reasoningUnseen };
867
+ const now = Date.now();
868
+ if (now - lastProgressPaint < 200)
869
+ return;
870
+ lastProgressPaint = now;
871
+ repaintCompacting();
872
+ },
770
873
  });
771
874
  if (result === null) {
772
875
  body.notice("[/compact] nothing to compact — fewer than 5 rounds yet");
@@ -828,8 +931,10 @@ export function dispatch(line, ctx) {
828
931
  }
829
932
  }
830
933
  finally {
831
- if (compactTimer !== null)
832
- clearInterval(compactTimer);
934
+ stopSpinner?.();
935
+ // ADR-0005 Amendment 2: a retry belongs to the call that
936
+ // announced it — none may outlive the compaction into the idle row.
937
+ setRetryShown(null);
833
938
  ctx.paintIdle();
834
939
  }
835
940
  ctx.input.prompt();
@@ -910,8 +1015,15 @@ export function dispatch(line, ctx) {
910
1015
  // would; the validation is the difference
911
1016
  if (!ctx.sessions().includes(arg)) {
912
1017
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
913
- bodyLog(`no such session: ${escapeTerminal(arg)} — /resume lists them`);
914
- ctx.input.prompt();
1018
+ const route = ctx.route?.(arg) ?? null;
1019
+ if (route === null || route.kind === "refused") {
1020
+ bodyLog(route?.line ?? `no such session: ${escapeTerminal(arg)} — /resume lists them`);
1021
+ ctx.input.prompt();
1022
+ return;
1023
+ }
1024
+ if (route.kind === "elsewhere" && route.line !== null)
1025
+ bodyLog(route.line);
1026
+ ctx.requestSwitch(arg);
915
1027
  });
916
1028
  return;
917
1029
  }
@@ -933,6 +1045,16 @@ export function dispatch(line, ctx) {
933
1045
  ctx.input.prompt(); // esc — nothing switched, nothing said
934
1046
  return;
935
1047
  }
1048
+ // 0.40.0: `tab` lists every project; another project's recorded
1049
+ // session is refused with where to go
1050
+ const route = ctx.sessions().includes(picked) ? null : (ctx.route?.(picked) ?? null);
1051
+ if (route?.kind === "refused") {
1052
+ bodyLog(route.line);
1053
+ ctx.input.prompt();
1054
+ return;
1055
+ }
1056
+ if (route?.kind === "elsewhere" && route.line !== null)
1057
+ bodyLog(route.line);
936
1058
  ctx.requestSwitch(picked);
937
1059
  });
938
1060
  return;
@@ -945,6 +1067,25 @@ export function dispatch(line, ctx) {
945
1067
  });
946
1068
  return;
947
1069
  }
1070
+ // 0.40.0 — a person's skill: `/skills`, `/skill <name> [args]`, or
1071
+ // `/<name> [args]` for a name no built-in above claimed. Placed AFTER
1072
+ // every built-in so a built-in always wins, and BEFORE the unknown-
1073
+ // command line so an installed skill is never called unknown.
1074
+ const skill = resolveSkillLine(trimmed, loadedSkillsCatalog(), slashCommandNames());
1075
+ if (trimmed === "/skills" || trimmed === "/skill" || skill !== null) {
1076
+ if (skill?.kind === "submit") {
1077
+ ctx.submitTurn(skill.content, skill.via);
1078
+ return;
1079
+ }
1080
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
1081
+ if (skill?.kind === "list")
1082
+ bodyLog(skillsRows(loadedSkillsCatalog(), skillSource, tildePath(join(kisoHome(), "skills"))).map(escapeTerminal).join("\n"));
1083
+ else if (skill?.kind === "error")
1084
+ bodyLog(escapeTerminal(skill.message));
1085
+ ctx.input.prompt();
1086
+ });
1087
+ return;
1088
+ }
948
1089
  // PH-1a (finding PH-F1): an unrecognized slash command is an ERROR,
949
1090
  // never a turn — the fallthrough used to hand "/clear", "/exit", or a
950
1091
  // 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;