@vincemakes/kiso-code 0.45.3 → 0.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mode.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Modes — the six built-in approval tiers, built ON the E1 policy chain
2
+ * Modes — the built-in approval tiers, built ON the E1 policy chain
3
3
  * (the kernel is untouched). Each tier is an in-process "mode:<name>"
4
4
  * extension whose decide() is live — it only speaks when it is the
5
5
  * CURRENT tier (otherwise abstain = no opinion, ADR-0042), so /mode
@@ -7,19 +7,37 @@
7
7
  * field: an automated denial records decidedBy: "mode:<name>" — the
8
8
  * audit sell. User-level extensions stay on the chain AFTER the mode
9
9
  * tiers; a user deny always wins (the chain's deny>allow>ask
10
- * monotonicity — bypass cannot override an extension deny).
10
+ * monotonicity — full-access cannot override an extension deny).
11
+ *
12
+ * Two questions, two settings (the modes round, 2026-09-30). The TIER
13
+ * answers what the agent may do. The DON'T-ASK SWITCH answers whether
14
+ * kiso may stop for a person, and it composes with every tier: it never
15
+ * grants anything — what would ask is refused, a model's question is not
16
+ * offered, an uncertain execution stays uncertain. The old `dontAsk` tier
17
+ * was that switch welded to default's authority; it is still accepted,
18
+ * and means exactly that.
11
19
  */
12
20
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
13
- export type Mode = "manual" | "default" | "accept-edits" | "plan" | "bypass" | "dontAsk";
14
- /** Every tier kiso ACCEPTS — from a config, KISO_MODE, --mode, or
15
- * `/mode <name>`. */
21
+ export type Mode = "manual" | "default" | "accept-edits" | "plan" | "full-access";
22
+ /** Every tier kiso decides with. */
16
23
  export declare const MODES: readonly Mode[];
17
- /** The tiers kiso OFFERS a person (launch-weekend plan §2): the picker,
18
- * shift+tab, the printed list, the help. `manual` left the offer — a
19
- * saved allow outranks its ask, so "asks for every tool" was never
20
- * quite what it did — and stays accepted, so no config that names it
21
- * breaks. `dontAsk` joined: the unattended tier. */
24
+ /** The tiers kiso OFFERS a person: the picker, shift+tab, the printed
25
+ * list, the help. `manual` left the offer at 0.40.0 — a saved allow
26
+ * outranks its ask, so "asks for every tool" was never quite what it
27
+ * did — and stays accepted, so no config that names it breaks. */
22
28
  export declare const OFFERED_MODES: readonly Mode[];
29
+ /** What a person reads for each tier — the status row, the picker, the
30
+ * notices. The typed value (config, --mode, KISO_MODE) keeps its hyphen. */
31
+ export declare const MODE_LABEL: Readonly<Record<Mode, string>>;
32
+ /** One tier plus, for the one spelling that carried it, the switch. */
33
+ export interface ModeSetting {
34
+ readonly mode: Mode;
35
+ readonly dontAsk?: true;
36
+ }
37
+ /** Every value a config, KISO_MODE, --mode or `/mode <name>` accepts. */
38
+ export declare const MODE_VALUES: readonly string[];
39
+ /** A typed tier: its value, its label ("full access"), or an old name. */
40
+ export declare function parseMode(raw: string): ModeSetting | undefined;
23
41
  /** DC-36 — one line per tier, for the picker, TRANSCRIBED FROM decide()
24
42
  * below rather than written fresh. A description that drifts from the
25
43
  * behaviour is worse than none: this is the row a human reads before
@@ -34,21 +52,76 @@ export declare const OFFERED_MODES: readonly Mode[];
34
52
  * is unchanged; the copy was the thing that was wrong. `plan` needs no
35
53
  * qualification because it DENIES, and a deny is what nothing overrides. */
36
54
  export declare const MODE_NOTE: Readonly<Record<Mode, string>>;
55
+ /** The switch in one line, for a surface that describes it, under the
56
+ * same 60-column rule. The /mode panel does not offer it: don't ask is
57
+ * not a tier, so it never sits beside them (owner, 2026-09-30) — the
58
+ * panel's header names it when it is on. */
59
+ export declare const DONT_ASK_NOTE = "never asks: what would ask is refused and the run goes on";
60
+ /** The switch. "old-name" is a switch that arrived bundled with the old
61
+ * `dontAsk` tier: it leaves with the tier, as leaving dontAsk always
62
+ * gave the asks back. A switch set on its own ("on") outlives every tier
63
+ * change. */
64
+ export type DontAskState = "off" | "on" | "old-name";
37
65
  export declare function getMode(): Mode;
66
+ /** A tier switch. The switch stays as it is — unless it came with the
67
+ * old dontAsk tier, which this switch leaves. */
38
68
  export declare function setMode(m: Mode): void;
39
- /** The startup mode from env: KISO_MODE, or undefined when unset (the
40
- * config layer's mode then applies — merge round B; the --mode flag is
41
- * applied by main before the first createCodingAgent and wins over everything). */
42
- export declare function modeFromEnv(): Mode | undefined;
43
- /** The six built-in mode tiers as chain extensions — named "mode:<tier>"
69
+ /** A typed tier, old names included: `/mode dontAsk` is default with the
70
+ * switch on, exactly the tier it used to be. */
71
+ export declare function applyModeSetting(s: ModeSetting): void;
72
+ /** Whether kiso may stop for a person — every ask endpoint reads this. */
73
+ export declare function getDontAsk(): boolean;
74
+ export declare function setDontAsk(on: boolean): void;
75
+ /** The tier as the status row and the notices name it, with the switch
76
+ * beside it when it is on. */
77
+ export declare function modeDisplay(): string;
78
+ /** One layer's say: what a config file, the environment or the command
79
+ * line wrote. KisoConfig is one of these. */
80
+ export interface ModeLayer {
81
+ readonly mode?: string | undefined;
82
+ readonly dontAsk?: boolean | undefined;
83
+ }
84
+ export type ModeSource = "flag" | "env" | "project" | "user" | "default";
85
+ export interface ModeState {
86
+ readonly mode: Mode;
87
+ readonly dontAsk: DontAskState;
88
+ /** the layer each answer came from — /settings names it */
89
+ readonly from: {
90
+ readonly mode: ModeSource;
91
+ readonly dontAsk: ModeSource;
92
+ };
93
+ }
94
+ /** KISO_MODE and KISO_DONT_ASK. A value kiso cannot read is no value —
95
+ * the next layer decides, as an unknown KISO_MODE always did. */
96
+ export declare function envModeLayer(env?: Readonly<Record<string, string | undefined>>): ModeLayer;
97
+ /**
98
+ * Precedence: flag > env > project config > user config > default, for
99
+ * each answer on its own. The tier comes from the highest layer that
100
+ * names a readable one. The switch comes from the highest layer that
101
+ * either sets it (`--dont-ask`, KISO_DONT_ASK, "dontAsk": true|false) or
102
+ * WON THE TIER with the old name `dontAsk` — an old name's switch counts
103
+ * only where that old name decided the tier. So every combination of old
104
+ * spellings resolves as it did when dontAsk was a tier: a user config
105
+ * saying "dontAsk" under `--mode bypass` still asks, because --mode won.
106
+ */
107
+ export declare function resolveModeLayers(layers: {
108
+ readonly flag?: ModeLayer | null | undefined;
109
+ readonly env?: ModeLayer | null | undefined;
110
+ readonly project?: ModeLayer | null | undefined;
111
+ readonly user?: ModeLayer | null | undefined;
112
+ }): ModeState;
113
+ /** Put a resolved state in force — startup, and again once the project
114
+ * config is known. */
115
+ export declare function applyModeState(s: ModeState): void;
116
+ /** The built-in mode tiers as chain extensions — named "mode:<tier>"
44
117
  * so the runtime's decidedBy records exactly that (the runtime derives
45
118
  * approvalPolicies from extensions[].approvals, tagging each with the
46
119
  * extension name). The CURRENT tier is first: an all-allow chain records
47
120
  * decidedBy = the FIRST SPEAKER, so an auto-allow under the startup mode
48
121
  * names that mode honestly. Order never affects verdicts — the chain is
49
122
  * deny>allow>ask over the SPEAKING verdicts (abstain = no opinion), so a
50
- * user extension's deny wins over any mode tier, bypass included (the
51
- * monotonicity e2e pins it). */
123
+ * user extension's deny wins over any mode tier, full-access included
124
+ * (the monotonicity e2e pins it). */
52
125
  export declare function modeExtensions(workspaceRoot?: () => string): readonly KisoExtension[];
53
126
  /** The plan tier's system prompt addition — injected at startup when the
54
127
  * initial mode is plan (the session prompt is fixed at creation; runtime
package/dist/mode.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Modes — the six built-in approval tiers, built ON the E1 policy chain
2
+ * Modes — the built-in approval tiers, built ON the E1 policy chain
3
3
  * (the kernel is untouched). Each tier is an in-process "mode:<name>"
4
4
  * extension whose decide() is live — it only speaks when it is the
5
5
  * CURRENT tier (otherwise abstain = no opinion, ADR-0042), so /mode
@@ -7,18 +7,52 @@
7
7
  * field: an automated denial records decidedBy: "mode:<name>" — the
8
8
  * audit sell. User-level extensions stay on the chain AFTER the mode
9
9
  * tiers; a user deny always wins (the chain's deny>allow>ask
10
- * monotonicity — bypass cannot override an extension deny).
10
+ * monotonicity — full-access cannot override an extension deny).
11
+ *
12
+ * Two questions, two settings (the modes round, 2026-09-30). The TIER
13
+ * answers what the agent may do. The DON'T-ASK SWITCH answers whether
14
+ * kiso may stop for a person, and it composes with every tier: it never
15
+ * grants anything — what would ask is refused, a model's question is not
16
+ * offered, an uncertain execution stays uncertain. The old `dontAsk` tier
17
+ * was that switch welded to default's authority; it is still accepted,
18
+ * and means exactly that.
11
19
  */
12
20
  import { isProtectedWrite } from "./protected-writes.js";
13
- /** Every tier kiso ACCEPTS — from a config, KISO_MODE, --mode, or
14
- * `/mode <name>`. */
15
- export const MODES = ["manual", "default", "accept-edits", "plan", "bypass", "dontAsk"];
16
- /** The tiers kiso OFFERS a person (launch-weekend plan §2): the picker,
17
- * shift+tab, the printed list, the help. `manual` left the offer — a
18
- * saved allow outranks its ask, so "asks for every tool" was never
19
- * quite what it did — and stays accepted, so no config that names it
20
- * breaks. `dontAsk` joined: the unattended tier. */
21
- export const OFFERED_MODES = ["default", "accept-edits", "plan", "dontAsk", "bypass"];
21
+ /** Every tier kiso decides with. */
22
+ export const MODES = ["manual", "default", "accept-edits", "plan", "full-access"];
23
+ /** The tiers kiso OFFERS a person: the picker, shift+tab, the printed
24
+ * list, the help. `manual` left the offer at 0.40.0 — a saved allow
25
+ * outranks its ask, so "asks for every tool" was never quite what it
26
+ * did — and stays accepted, so no config that names it breaks. */
27
+ export const OFFERED_MODES = ["default", "accept-edits", "plan", "full-access"];
28
+ /** What a person reads for each tier — the status row, the picker, the
29
+ * notices. The typed value (config, --mode, KISO_MODE) keeps its hyphen. */
30
+ export const MODE_LABEL = {
31
+ manual: "manual",
32
+ default: "default",
33
+ "accept-edits": "accept edits",
34
+ plan: "plan",
35
+ "full-access": "full access",
36
+ };
37
+ /** Every spelling kiso ever accepted keeps its meaning. `bypass` is
38
+ * full-access's old name. `dontAsk` was a tier: default's decisions with
39
+ * the switch on — so it still means that, and never full-access with the
40
+ * switch on (read that way, an old config would gain authority nobody
41
+ * gave it). */
42
+ const OLD_NAMES = new Map([
43
+ ["bypass", { mode: "full-access" }],
44
+ ["dontAsk", { mode: "default", dontAsk: true }],
45
+ ]);
46
+ /** Every value a config, KISO_MODE, --mode or `/mode <name>` accepts. */
47
+ export const MODE_VALUES = [...MODES, ...OLD_NAMES.keys()];
48
+ /** A typed tier: its value, its label ("full access"), or an old name. */
49
+ export function parseMode(raw) {
50
+ const s = raw.trim();
51
+ const m = MODES.find((x) => x === s || MODE_LABEL[x] === s);
52
+ if (m !== undefined)
53
+ return { mode: m };
54
+ return OLD_NAMES.get(s);
55
+ }
22
56
  /** DC-36 — one line per tier, for the picker, TRANSCRIBED FROM decide()
23
57
  * below rather than written fresh. A description that drifts from the
24
58
  * behaviour is worse than none: this is the row a human reads before
@@ -41,25 +75,95 @@ export const MODE_NOTE = {
41
75
  default: "read-only runs; the rest asks — a saved allow still allows",
42
76
  "accept-edits": "read-only, edits run; rest asks — a saved allow still allows",
43
77
  plan: "reads run; all else is denied — read-only, and a deny wins",
44
- bypass: "everything runs, nothing asks — a user deny still wins",
45
- dontAsk: "asks nothing: an ask is denied — a saved allow still allows",
78
+ "full-access": "runs without asking — a user deny and the floor still win",
46
79
  };
80
+ /** The switch in one line, for a surface that describes it, under the
81
+ * same 60-column rule. The /mode panel does not offer it: don't ask is
82
+ * not a tier, so it never sits beside them (owner, 2026-09-30) — the
83
+ * panel's header names it when it is on. */
84
+ export const DONT_ASK_NOTE = "never asks: what would ask is refused and the run goes on";
47
85
  /** The read-only tool set (plan): reading is allowed, everything else
48
86
  * denied with the guiding reason. */
49
87
  const READ_TOOLS = new Set(["read_file", "list_dir", "search_text", "read_skill"]);
88
+ /** ADR-0058 (3b, D2): stopping a task this session started asks no one —
89
+ * stopping is the safe direction — in every tier but manual. */
90
+ const STOP_TOOLS = new Set(["task_stop"]);
50
91
  let current = "default";
92
+ let dontAsk = "off";
51
93
  export function getMode() {
52
94
  return current;
53
95
  }
96
+ /** A tier switch. The switch stays as it is — unless it came with the
97
+ * old dontAsk tier, which this switch leaves. */
54
98
  export function setMode(m) {
55
99
  current = m;
100
+ if (dontAsk === "old-name")
101
+ dontAsk = "off";
102
+ }
103
+ /** A typed tier, old names included: `/mode dontAsk` is default with the
104
+ * switch on, exactly the tier it used to be. */
105
+ export function applyModeSetting(s) {
106
+ setMode(s.mode);
107
+ if (s.dontAsk === true && dontAsk === "off")
108
+ dontAsk = "old-name";
109
+ }
110
+ /** Whether kiso may stop for a person — every ask endpoint reads this. */
111
+ export function getDontAsk() {
112
+ return dontAsk !== "off";
113
+ }
114
+ export function setDontAsk(on) {
115
+ dontAsk = on ? "on" : "off";
116
+ }
117
+ /** The tier as the status row and the notices name it, with the switch
118
+ * beside it when it is on. */
119
+ export function modeDisplay() {
120
+ return `${current === "plan" ? "plan (read-only)" : MODE_LABEL[current]}${dontAsk !== "off" ? " · don't ask" : ""}`;
121
+ }
122
+ /** KISO_MODE and KISO_DONT_ASK. A value kiso cannot read is no value —
123
+ * the next layer decides, as an unknown KISO_MODE always did. */
124
+ export function envModeLayer(env = process.env) {
125
+ const raw = env.KISO_DONT_ASK?.trim().toLowerCase();
126
+ const on = raw === "1" || raw === "true" || raw === "on" ? true : raw === "0" || raw === "false" || raw === "off" ? false : undefined;
127
+ return { ...(env.KISO_MODE !== undefined ? { mode: env.KISO_MODE } : {}), ...(on !== undefined ? { dontAsk: on } : {}) };
128
+ }
129
+ /**
130
+ * Precedence: flag > env > project config > user config > default, for
131
+ * each answer on its own. The tier comes from the highest layer that
132
+ * names a readable one. The switch comes from the highest layer that
133
+ * either sets it (`--dont-ask`, KISO_DONT_ASK, "dontAsk": true|false) or
134
+ * WON THE TIER with the old name `dontAsk` — an old name's switch counts
135
+ * only where that old name decided the tier. So every combination of old
136
+ * spellings resolves as it did when dontAsk was a tier: a user config
137
+ * saying "dontAsk" under `--mode bypass` still asks, because --mode won.
138
+ */
139
+ export function resolveModeLayers(layers) {
140
+ const order = [
141
+ ["flag", layers.flag],
142
+ ["env", layers.env],
143
+ ["project", layers.project],
144
+ ["user", layers.user],
145
+ ];
146
+ let tier = { setting: { mode: "default" }, from: "default" };
147
+ for (const [from, layer] of order) {
148
+ const setting = layer?.mode === undefined ? undefined : parseMode(layer.mode);
149
+ if (setting !== undefined) {
150
+ tier = { setting, from };
151
+ break;
152
+ }
153
+ }
154
+ for (const [from, layer] of order) {
155
+ if (layer?.dontAsk !== undefined)
156
+ return { mode: tier.setting.mode, dontAsk: layer.dontAsk ? "on" : "off", from: { mode: tier.from, dontAsk: from } };
157
+ if (from === tier.from && tier.setting.dontAsk === true)
158
+ return { mode: tier.setting.mode, dontAsk: "old-name", from: { mode: tier.from, dontAsk: from } };
159
+ }
160
+ return { mode: tier.setting.mode, dontAsk: "off", from: { mode: tier.from, dontAsk: "default" } };
56
161
  }
57
- /** The startup mode from env: KISO_MODE, or undefined when unset (the
58
- * config layer's mode then applies — merge round B; the --mode flag is
59
- * applied by main before the first createCodingAgent and wins over everything). */
60
- export function modeFromEnv() {
61
- const raw = process.env.KISO_MODE;
62
- return MODES.find((x) => x === raw);
162
+ /** Put a resolved state in force — startup, and again once the project
163
+ * config is known. */
164
+ export function applyModeState(s) {
165
+ current = s.mode;
166
+ dontAsk = s.dontAsk;
63
167
  }
64
168
  /** The per-tier verdict for a tool call — only when this tier is current. */
65
169
  function tierVerdict(tier, call, workspaceRoot) {
@@ -68,14 +172,13 @@ function tierVerdict(tier, call, workspaceRoot) {
68
172
  switch (tier) {
69
173
  case "manual":
70
174
  return { action: "ask" }; // every tool asks
71
- // dontAsk decides exactly as default does. What makes it dontAsk is
72
- // what happens to the chain's final ASK: the CLI denies it at the
73
- // ask endpoint instead of asking (chat.ts). Not a tier deny — that
74
- // would outrank every allow, and a saved allow and the read-only
75
- // shell allow must still allow.
175
+ // The don't-ask switch changes no tier's decision. What it changes is
176
+ // what happens to the chain's final ASK: the CLI denies it at the ask
177
+ // endpoint instead of asking (chat.ts). Not a tier deny — that would
178
+ // outrank every allow, and a saved allow and the read-only shell allow
179
+ // must still allow.
76
180
  case "default":
77
- case "dontAsk":
78
- if (READ_TOOLS.has(call.name))
181
+ if (READ_TOOLS.has(call.name) || STOP_TOOLS.has(call.name))
79
182
  return { action: "allow" };
80
183
  if (call.name === "write_file" || call.name === "edit_file" || call.name === "shell")
81
184
  return { action: "ask" };
@@ -86,7 +189,7 @@ function tierVerdict(tier, call, workspaceRoot) {
86
189
  // finding: "allow"-as-no-opinion auto-approved it).
87
190
  return { action: "abstain" };
88
191
  case "accept-edits":
89
- if (READ_TOOLS.has(call.name))
192
+ if (READ_TOOLS.has(call.name) || STOP_TOOLS.has(call.name))
90
193
  return { action: "allow" };
91
194
  // 0.40.0: a write into .git/ or .kiso/ asks even here — both hold
92
195
  // configuration that runs (protected-writes.ts)
@@ -96,22 +199,22 @@ function tierVerdict(tier, call, workspaceRoot) {
96
199
  return { action: "ask" };
97
200
  return { action: "abstain" }; // see "default"
98
201
  case "plan":
99
- if (READ_TOOLS.has(call.name))
202
+ if (READ_TOOLS.has(call.name) || STOP_TOOLS.has(call.name))
100
203
  return { action: "allow" };
101
204
  return { action: "deny", reason: "plan mode: read-only" };
102
- case "bypass":
205
+ case "full-access":
103
206
  return { action: "allow" }; // everything — a REAL allow, never an abstain
104
207
  }
105
208
  }
106
- /** The six built-in mode tiers as chain extensions — named "mode:<tier>"
209
+ /** The built-in mode tiers as chain extensions — named "mode:<tier>"
107
210
  * so the runtime's decidedBy records exactly that (the runtime derives
108
211
  * approvalPolicies from extensions[].approvals, tagging each with the
109
212
  * extension name). The CURRENT tier is first: an all-allow chain records
110
213
  * decidedBy = the FIRST SPEAKER, so an auto-allow under the startup mode
111
214
  * names that mode honestly. Order never affects verdicts — the chain is
112
215
  * deny>allow>ask over the SPEAKING verdicts (abstain = no opinion), so a
113
- * user extension's deny wins over any mode tier, bypass included (the
114
- * monotonicity e2e pins it). */
216
+ * user extension's deny wins over any mode tier, full-access included
217
+ * (the monotonicity e2e pins it). */
115
218
  export function modeExtensions(workspaceRoot = () => process.cwd()) {
116
219
  return [...MODES.filter((m) => m === current), ...MODES.filter((m) => m !== current)].map((m) => ({
117
220
  name: `mode:${m}`,
@@ -1,6 +1,10 @@
1
+ import type { ModelProfile } from "./config.js";
1
2
  export interface Preferences {
2
3
  /** ctrl+t: the thinking blocks as one italic line each. Absent = shown. */
3
4
  readonly thinking?: "shown" | "hidden";
5
+ /** B1: the effort last picked for each profile (`/model <p> <e>`, or the
6
+ * panel with a level) — where a NEW session of that profile starts. */
7
+ readonly effort?: Readonly<Record<string, string>>;
4
8
  }
5
9
  export declare function preferencesPath(home?: string): string;
6
10
  /** Startup: read the file, and keep choices made from here on in it. */
@@ -10,5 +14,15 @@ export declare function preferences(): Preferences;
10
14
  /** Remember a choice. Returns false when no file was named. The file is
11
15
  * re-read before the write, so another process's choice is kept. */
12
16
  export declare function setPreference<K extends keyof Preferences>(key: K, value: NonNullable<Preferences[K]>): boolean;
17
+ /** B1: remember an explicit effort pick for a profile. */
18
+ export declare function rememberEffort(profile: string, effort: string): boolean;
19
+ /**
20
+ * B1: the effort a NEW session of `profile` starts at, from the one last
21
+ * picked — the picker's fallback: the level itself when the model has it,
22
+ * else the endpoint's registry default, else none (default/default, no
23
+ * reasoning key). Only a level that resolves for the model is returned, so
24
+ * the session never carries a setting its first request would refuse.
25
+ */
26
+ export declare function startingEffort(profile: Pick<ModelProfile, "model" | "baseUrl">, remembered: string | undefined): string | null;
13
27
  /** Tests: back to the state before startup. */
14
28
  export declare function resetPreferences(): void;
@@ -3,8 +3,8 @@
3
3
  * `<KISO_HOME>/preferences.json`.
4
4
  *
5
5
  * `config.json` is the human's file: kiso reads it and never writes it. A
6
- * choice made with a key — ctrl+t's thinking display, today the only one —
7
- * has to survive a restart, so it lands here instead: kiso-owned, private
6
+ * choice made with a key — ctrl+t's thinking display, and (B1) the effort
7
+ * last picked for each profile — has to survive a restart, so it lands here instead: kiso-owned, private
8
8
  * (0600, tmp + rename), and read back at start-up. A missing or unreadable
9
9
  * file is an empty one; an unknown value is ignored rather than guessed.
10
10
  *
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
16
  import { dirname, join } from "node:path";
17
+ import { lookupModelMetadata, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
17
18
  import { kisoHome } from "./state.js";
18
19
  let prefs = null;
19
20
  let prefsPath = null;
@@ -23,7 +24,15 @@ export function preferencesPath(home = kisoHome()) {
23
24
  function read(path) {
24
25
  try {
25
26
  const raw = JSON.parse(readFileSync(path, "utf8"));
26
- return raw.thinking === "shown" || raw.thinking === "hidden" ? { thinking: raw.thinking } : {};
27
+ const out = {};
28
+ if (raw.thinking === "shown" || raw.thinking === "hidden")
29
+ out.thinking = raw.thinking;
30
+ if (raw.effort !== null && typeof raw.effort === "object" && !Array.isArray(raw.effort)) {
31
+ const effort = Object.fromEntries(Object.entries(raw.effort).filter((e) => typeof e[1] === "string"));
32
+ if (Object.keys(effort).length > 0)
33
+ out.effort = effort;
34
+ }
35
+ return out;
27
36
  }
28
37
  catch {
29
38
  return {};
@@ -57,6 +66,28 @@ export function setPreference(key, value) {
57
66
  }
58
67
  return true;
59
68
  }
69
+ /** B1: remember an explicit effort pick for a profile. */
70
+ export function rememberEffort(profile, effort) {
71
+ return setPreference("effort", { ...(preferences().effort ?? {}), [profile]: effort });
72
+ }
73
+ /**
74
+ * B1: the effort a NEW session of `profile` starts at, from the one last
75
+ * picked — the picker's fallback: the level itself when the model has it,
76
+ * else the endpoint's registry default, else none (default/default, no
77
+ * reasoning key). Only a level that resolves for the model is returned, so
78
+ * the session never carries a setting its first request would refuse.
79
+ */
80
+ export function startingEffort(profile, remembered) {
81
+ if (remembered === undefined || remembered === "default")
82
+ return null;
83
+ const effort = lookupModelMetadata(profile.model, profile.baseUrl)?.capabilities.reasoning?.effort ?? null;
84
+ if (effort === null)
85
+ return null;
86
+ const legal = (level) => resolveReasoning(profile.model, { thinking: "default", effort: level }, profile.baseUrl).ok;
87
+ if (effort.levels.includes(remembered) && legal(remembered))
88
+ return remembered;
89
+ return effort.default !== null && legal(effort.default) ? effort.default : null;
90
+ }
60
91
  /** Tests: back to the state before startup. */
61
92
  export function resetPreferences() {
62
93
  prefs = null;
@@ -35,10 +35,10 @@
35
35
  * guarantee is the sandbox, which stays post-launch.
36
36
  */
37
37
  import { readdirSync, realpathSync, statSync } from "node:fs";
38
- import { basename, dirname, resolve } from "node:path";
38
+ import { basename } from "node:path";
39
39
  import { protectedIdentity, PROTECTED_REFUSAL } from "@vincemakes/kiso-tools-node";
40
40
  import { innerLines, unwrap } from "./floor.js";
41
- import { parseShellLooseChecked } from "./shell-words.js";
41
+ import { hostDialect, msysPath, parseShellLooseChecked, resolveShellPath } from "./shell-words.js";
42
42
  const ABSTAIN = { action: "abstain" };
43
43
  /** How deep `sh -c`/`eval` lines are followed, and how many are read. */
44
44
  const MAX_INNER_DEPTH = 16;
@@ -104,7 +104,8 @@ function globRegex(glob) {
104
104
  // case-folded: on a disk that folds case, `.KISO/*` is the same directory
105
105
  return new RegExp(`^${re}$`, "i");
106
106
  }
107
- function probeFor(id) {
107
+ function probeFor(id, { msys, path }) {
108
+ const { basename, dirname, join } = path;
108
109
  const files = new Set();
109
110
  const dirs = new Set();
110
111
  const targets = [];
@@ -115,7 +116,7 @@ function probeFor(id) {
115
116
  try {
116
117
  for (const name of readdirSync(dir))
117
118
  if (name.startsWith(`${base}.`))
118
- targets.push(`${dir}/${name}`);
119
+ targets.push(msys ? join(dir, name) : `${dir}/${name}`);
119
120
  }
120
121
  catch {
121
122
  // no directory yet — nothing in it to serve
@@ -139,8 +140,10 @@ function probeFor(id) {
139
140
  export function protectedShellCheck(line, workspaceRoot, id, env) {
140
141
  if (id.paths.length === 0)
141
142
  return { hit: false };
142
- const probe = probeFor(id);
143
- const names = id.paths.map((p) => basename(p).toLowerCase());
143
+ const dialect = hostDialect();
144
+ const P = dialect.path;
145
+ const probe = probeFor(id, dialect);
146
+ const names = id.paths.map((p) => P.basename(p).toLowerCase());
144
147
  const paths = id.paths.map((p) => p.toLowerCase());
145
148
  const namesProtected = (t) => {
146
149
  const low = t.toLowerCase();
@@ -168,7 +171,17 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
168
171
  seenStat.set(p, st);
169
172
  return st;
170
173
  };
171
- const at = (cwd, t) => (t.startsWith("/") ? t : `${cwd}/${t}`);
174
+ // on Windows the system normalizes a path lexically before it opens it,
175
+ // so the lexical resolution is what a command opens there
176
+ const at = (cwd, t) => (dialect.msys ? P.resolve(cwd, t) : t.startsWith("/") ? t : `${cwd}/${t}`);
177
+ /** Windows P2: a word as Git Bash's command sees the path — or null
178
+ * when it cannot be read (only its spelling can be checked). */
179
+ const native = (t) => {
180
+ if (!dialect.msys)
181
+ return t;
182
+ const read = msysPath(t);
183
+ return "opaque" in read ? null : read.native;
184
+ };
172
185
  const regexes = new Map();
173
186
  const realOf = new Map();
174
187
  /** Whether a word's last component is a protected file's name, or one
@@ -183,12 +196,15 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
183
196
  return out.replace(/\$\{KISO_HOME\}|\$KISO_HOME(?![A-Za-z0-9_])/g, env.kisoHome);
184
197
  };
185
198
  /** One literal-or-glob path text, read from each candidate directory. */
186
- const pathHits = (t, s) => {
187
- if (t === "")
199
+ const pathHits = (word, s) => {
200
+ if (word === "")
188
201
  return false;
189
- if (/[$`]/.test(t))
190
- return namesProtected(t);
191
- if (s.lossy && !t.startsWith("/") && namesProtected(t))
202
+ if (/[$`]/.test(word))
203
+ return namesProtected(word);
204
+ const t = native(word);
205
+ if (t === null)
206
+ return namesProtected(word);
207
+ if (s.lossy && !P.isAbsolute(t) && namesProtected(t))
192
208
  return true;
193
209
  const glob = /[*?[]/.test(t);
194
210
  for (const cwd of s.cwds) {
@@ -198,13 +214,31 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
198
214
  // not exist yet) — only when the last component could be the
199
215
  // file's name — then by inode
200
216
  if (named(t)) {
201
- const spelled = resolve(cwd, t).toLowerCase();
217
+ const spelled = P.resolve(cwd, t).toLowerCase();
202
218
  if (paths.some((pf) => spelled === pf || spelled.startsWith(`${pf}.`)))
203
219
  return true;
204
220
  }
205
221
  const st = statAt(at(cwd, t));
206
222
  if (st?.isFile() === true && probe.files.has(inode(st)))
207
223
  return true;
224
+ if (dialect.msys && t.split("/").includes("..")) {
225
+ // Windows: the lexical reading above, and — for a `..`, where
226
+ // they can differ — the real-path one; a `..` after a link the
227
+ // two read apart falls back to names. One more reading charged.
228
+ charge();
229
+ const real = resolveShellPath(cwd, cwd, t, dialect);
230
+ if (real.opaque !== undefined) {
231
+ if (namesProtected(t))
232
+ return true;
233
+ continue;
234
+ }
235
+ const low = real.canonical.toLowerCase();
236
+ if (paths.some((pf) => low === pf || low.startsWith(`${pf}.`)))
237
+ return true;
238
+ const rst = statAt(real.canonical);
239
+ if (rst?.isFile() === true && probe.files.has(inode(rst)))
240
+ return true;
241
+ }
208
242
  continue;
209
243
  }
210
244
  const g = t.search(/[*?[]/);
@@ -232,11 +266,14 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
232
266
  }
233
267
  realOf.set(dirText, real);
234
268
  }
235
- const pattern = `${real.endsWith("/") ? real : `${real}/`}${rest}`;
269
+ // Windows: one separator, one case, on both sides of the match
270
+ const fold = (p) => (dialect.msys ? p.replace(/\\/g, "/").toLowerCase() : p);
271
+ const dirReal = fold(real);
272
+ const pattern = `${dirReal.endsWith("/") ? dirReal : `${dirReal}/`}${dialect.msys ? rest.toLowerCase() : rest}`;
236
273
  let re = regexes.get(pattern);
237
274
  if (re === undefined)
238
275
  regexes.set(pattern, (re = globRegex(pattern)));
239
- if (probe.targets.some((p) => re.test(p)))
276
+ if (probe.targets.some((p) => re.test(fold(p))))
240
277
  return true;
241
278
  }
242
279
  return false;
@@ -255,8 +292,11 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
255
292
  /** A directory holding a protected file — the file's own, or any
256
293
  * directory above it — by inode. */
257
294
  const holds = (w, s) => {
258
- const t = expand(w.text, w.tilde);
259
- if (t === "" || /[$`*?[]/.test(t) || (w.tilde && /^~[^/]/.test(w.text)))
295
+ const spelled = expand(w.text, w.tilde);
296
+ if (spelled === "" || /[$`*?[]/.test(spelled) || (w.tilde && /^~[^/]/.test(w.text)))
297
+ return false;
298
+ const t = native(spelled);
299
+ if (t === null)
260
300
  return false;
261
301
  return s.cwds.some((cwd) => {
262
302
  charge();
@@ -334,8 +374,9 @@ export function protectedShellCheck(line, workspaceRoot, id, env) {
334
374
  else if (to === undefined)
335
375
  s.pending = [env.home];
336
376
  else {
337
- const t = expand(to.text, to.tilde);
338
- if (/[$`*?[]/.test(t) || (to.tilde && /^~[^/]/.test(to.text))) {
377
+ const spelled = expand(to.text, to.tilde);
378
+ const t = native(spelled);
379
+ if (t === null || /[$`*?[]/.test(spelled) || (to.tilde && /^~[^/]/.test(to.text))) {
339
380
  s.lossy = true;
340
381
  s.pending = cap(s, [env.home, ...s.cwds]);
341
382
  }
@@ -12,8 +12,8 @@
12
12
  * So a write_file or edit_file whose path — as written, or once symlinks
13
13
  * are followed — passes through a `.git` or `.kiso` directory asks in
14
14
  * every asking tier: accept-edits' allow does not cover it, and neither
15
- * does a saved allow. dontAsk therefore denies it; plan denies it anyway;
16
- * bypass runs it, as bypass's shell could.
15
+ * does a saved allow. With the don't-ask switch on it is therefore denied;
16
+ * plan denies it anyway; full-access runs it, as full-access's shell could.
17
17
  */
18
18
  import type { PolicyCall } from "@vincemakes/kiso-core";
19
19
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
@@ -12,8 +12,8 @@
12
12
  * So a write_file or edit_file whose path — as written, or once symlinks
13
13
  * are followed — passes through a `.git` or `.kiso` directory asks in
14
14
  * every asking tier: accept-edits' allow does not cover it, and neither
15
- * does a saved allow. dontAsk therefore denies it; plan denies it anyway;
16
- * bypass runs it, as bypass's shell could.
15
+ * does a saved allow. With the don't-ask switch on it is therefore denied;
16
+ * plan denies it anyway; full-access runs it, as full-access's shell could.
17
17
  */
18
18
  import { relative, sep } from "node:path";
19
19
  import { resolveShellPath } from "./shell-words.js";