@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.
- package/README.md +1 -1
- package/dist/attachments.d.ts +1 -1
- package/dist/attachments.js +11 -4
- package/dist/builtin.d.ts +12 -0
- package/dist/builtin.js +22 -1
- package/dist/chat.d.ts +31 -3
- package/dist/chat.js +169 -30
- package/dist/config.d.ts +11 -1
- package/dist/config.js +34 -3
- package/dist/dispatch.d.ts +12 -2
- package/dist/dispatch.js +165 -24
- package/dist/floor.d.ts +80 -0
- package/dist/floor.js +652 -0
- package/dist/index.d.ts +21 -2
- package/dist/index.js +463 -46
- package/dist/mode.d.ts +12 -4
- package/dist/mode.js +32 -10
- package/dist/projects.d.ts +106 -0
- package/dist/projects.js +212 -0
- package/dist/protected-shell.d.ts +88 -0
- package/dist/protected-shell.js +406 -0
- package/dist/protected-writes.d.ts +29 -0
- package/dist/protected-writes.js +54 -0
- package/dist/provider-label.d.ts +32 -0
- package/dist/provider-label.js +53 -0
- package/dist/readonly-shell.d.ts +58 -0
- package/dist/readonly-shell.js +590 -0
- package/dist/replay.d.ts +53 -0
- package/dist/replay.js +158 -0
- package/dist/resume-tail.js +4 -1
- package/dist/resume.js +3 -3
- package/dist/retries.d.ts +9 -0
- package/dist/retries.js +16 -0
- package/dist/session-cards.d.ts +33 -4
- package/dist/session-cards.js +44 -3
- package/dist/session-migration.d.ts +104 -0
- package/dist/session-migration.js +319 -0
- package/dist/shell-words.d.ts +134 -0
- package/dist/shell-words.js +628 -0
- package/dist/skill-invoke.d.ts +58 -0
- package/dist/skill-invoke.js +139 -0
- package/dist/state.d.ts +80 -1
- package/dist/state.js +113 -2
- package/dist/trust-ui.js +29 -5
- 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");
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
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:
|
|
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 ?
|
|
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: ${
|
|
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: ${
|
|
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: ${
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
832
|
-
|
|
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
|
-
|
|
914
|
-
|
|
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
|
package/dist/floor.d.ts
ADDED
|
@@ -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;
|