@vincemakes/kiso-code 0.40.5 → 0.40.7

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.
@@ -10,6 +10,10 @@ export type Credential = {
10
10
  readonly expires: number;
11
11
  readonly accountId?: string;
12
12
  readonly savedAt: number;
13
+ /** 0.40.7: when the token endpoint REFUSED this credential's
14
+ * refresh token. Set only by that refusal (never by a network
15
+ * failure); a new login replaces the whole entry and so clears it. */
16
+ readonly refreshRejectedAt?: number;
13
17
  };
14
18
  export interface AuthFile {
15
19
  readonly version: 1;
@@ -49,6 +53,18 @@ export declare function effectiveBaseUrl(kind: string, baseUrl?: string): string
49
53
  /** The provider identity a profile's credential is stored under, or null
50
54
  * for an origin nobody recognizes (custom: env var only). */
51
55
  export declare function providerIdOf(kind: string, baseUrl?: string): string | null;
56
+ /**
57
+ * 0.40.6 — the store id for a GATEWAY's own key: `endpoint:<origin>`.
58
+ *
59
+ * A vendor's credential is stored under the vendor and only ever reaches
60
+ * the vendor's origin (R1 above). A gateway has no vendor identity, so its
61
+ * key is stored under the one thing that names where it may go — its
62
+ * ORIGIN (scheme, host, port). `kiso login --endpoint <url>` writes it; a
63
+ * profile whose endpoint has exactly that origin reads it; no other origin
64
+ * ever does, a local forwarder in front of the gateway included. Null for
65
+ * anything that is not an http(s) URL — never a guess.
66
+ */
67
+ export declare function endpointCredentialId(url: string | undefined): string | null;
52
68
  export declare function authPath(): string;
53
69
  export declare function readAuthFile(path?: string): AuthFile;
54
70
  export declare function getCredential(providerId: string, path?: string): Credential | undefined;
@@ -133,6 +133,31 @@ function originOf(baseUrl) {
133
133
  return null;
134
134
  }
135
135
  }
136
+ /**
137
+ * 0.40.6 — the store id for a GATEWAY's own key: `endpoint:<origin>`.
138
+ *
139
+ * A vendor's credential is stored under the vendor and only ever reaches
140
+ * the vendor's origin (R1 above). A gateway has no vendor identity, so its
141
+ * key is stored under the one thing that names where it may go — its
142
+ * ORIGIN (scheme, host, port). `kiso login --endpoint <url>` writes it; a
143
+ * profile whose endpoint has exactly that origin reads it; no other origin
144
+ * ever does, a local forwarder in front of the gateway included. Null for
145
+ * anything that is not an http(s) URL — never a guess.
146
+ */
147
+ export function endpointCredentialId(url) {
148
+ if (url === undefined)
149
+ return null;
150
+ let u;
151
+ try {
152
+ u = new URL(url);
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ if (u.protocol !== "https:" && u.protocol !== "http:")
158
+ return null;
159
+ return `endpoint:${u.origin}`;
160
+ }
136
161
  export function authPath() {
137
162
  return join(kisoHome(), "auth.json");
138
163
  }
@@ -1,4 +1,4 @@
1
- import type { OAuthCredential, OAuthFlow } from "./index.js";
1
+ import { type OAuthCredential, type OAuthFlow } from "./index.js";
2
2
  export declare const CHATGPT: {
3
3
  readonly providerId: "chatgpt";
4
4
  readonly clientId: "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -17,6 +17,7 @@
17
17
  * endpoint — the Responses adapter is the second half of this step.
18
18
  */
19
19
  import { createServer } from "node:http";
20
+ import { RefreshRejectedError } from "./index.js";
20
21
  import { generatePkce, randomState } from "./pkce.js";
21
22
  export const CHATGPT = {
22
23
  providerId: "chatgpt",
@@ -86,7 +87,11 @@ export function accountIdOf(accessToken) {
86
87
  async function readTokenResponse(response, operation) {
87
88
  if (!response.ok) {
88
89
  const text = await response.text().catch(() => "");
89
- throw new Error(`ChatGPT sign-in: token ${operation} failed (${response.status})${text ? `: ${text.slice(0, 200)}` : ""}`);
90
+ const message = `ChatGPT sign-in: token ${operation} failed (${response.status})${text ? `: ${text.slice(0, 200)}` : ""}`;
91
+ // the endpoint's own refusal of the refresh token — the sign-in is over
92
+ if (operation === "refresh" && (response.status === 400 || response.status === 401))
93
+ throw new RefreshRejectedError(message);
94
+ throw new Error(message);
90
95
  }
91
96
  const json = (await response.json());
92
97
  if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
@@ -8,6 +8,14 @@ import type { Credential } from "../credentials.js";
8
8
  export type OAuthCredential = Extract<Credential, {
9
9
  type: "oauth";
10
10
  }>;
11
+ /**
12
+ * 0.40.7 — a refresh the token endpoint REFUSED (400 / 401: the refresh
13
+ * token was revoked or has expired), as distinct from one that never got an
14
+ * answer. Only this marks a sign-in dead (`refreshRejectedAt`); a network
15
+ * failure leaves the credential as it was, to be tried again.
16
+ */
17
+ export declare class RefreshRejectedError extends Error {
18
+ }
11
19
  export interface LoginInteraction {
12
20
  /** Print a line for the person signing in (the URL to open, the paste prompt). */
13
21
  readonly notify: (line: string) => void;
@@ -1 +1,8 @@
1
- export {};
1
+ /**
2
+ * 0.40.7 — a refresh the token endpoint REFUSED (400 / 401: the refresh
3
+ * token was revoked or has expired), as distinct from one that never got an
4
+ * answer. Only this marks a sign-in dead (`refreshRejectedAt`); a network
5
+ * failure leaves the credential as it was, to be tried again.
6
+ */
7
+ export class RefreshRejectedError extends Error {
8
+ }
@@ -7,6 +7,6 @@
7
7
  * never a silent fall back to an API key or an env var.
8
8
  */
9
9
  import { type Credential } from "./credentials.js";
10
- import type { OAuthFlow } from "./oauth/index.js";
10
+ import { type OAuthFlow } from "./oauth/index.js";
11
11
  export declare const FRESH_WINDOW_MS: number;
12
12
  export declare function ensureFresh(providerId: string, flow: OAuthFlow, path?: string, now?: number, tokenUrl?: string): Promise<Credential>;
@@ -7,6 +7,7 @@
7
7
  * never a silent fall back to an API key or an env var.
8
8
  */
9
9
  import { AuthError, modifyAuthFile, readAuthFile } from "./credentials.js";
10
+ import { RefreshRejectedError } from "./oauth/index.js";
10
11
  export const FRESH_WINDOW_MS = 5 * 60_000;
11
12
  export async function ensureFresh(providerId, flow, path, now = Date.now(), tokenUrl) {
12
13
  const current = readAuthFile(path).credentials[providerId];
@@ -14,6 +15,10 @@ export async function ensureFresh(providerId, flow, path, now = Date.now(), toke
14
15
  throw new AuthError(`not signed in to ${providerId}: run \`kiso login ${providerId}\``);
15
16
  if (current.type !== "oauth" || current.expires - now > FRESH_WINDOW_MS)
16
17
  return current;
18
+ // 0.40.7: a refresh token the endpoint already refused is not sent again —
19
+ // the answer would be the same refusal, one round trip later
20
+ if (current.refreshRejectedAt !== undefined)
21
+ throw new AuthError(`signed out of ${providerId} (the sign-in was refused when kiso tried to renew it) — run \`kiso login ${providerId}\``);
17
22
  let refreshed;
18
23
  let error;
19
24
  // the double check: another process may have refreshed while we waited for the lock
@@ -26,8 +31,22 @@ export async function ensureFresh(providerId, flow, path, now = Date.now(), toke
26
31
  catch (err) {
27
32
  error = err instanceof Error ? err : new Error(String(err));
28
33
  }
29
- if (refreshed === undefined)
34
+ if (refreshed === undefined) {
35
+ // 0.40.7: the endpoint's REFUSAL is remembered on the credential, so
36
+ // /model can say the sign-in is over before a turn finds out. The
37
+ // same generation rule as the write-back below (R2a): a logout or a
38
+ // new login while the refresh was on the wire is left alone.
39
+ if (error instanceof RefreshRejectedError) {
40
+ const began = current;
41
+ modifyAuthFile((file) => {
42
+ const latest = file.credentials[providerId];
43
+ if (latest === undefined || latest.type !== "oauth" || latest.refresh !== began.refresh || latest.expires !== began.expires)
44
+ return file;
45
+ return { version: 1, credentials: { ...file.credentials, [providerId]: { ...latest, refreshRejectedAt: now } } };
46
+ }, path);
47
+ }
30
48
  throw new AuthError(`signed out of ${providerId} (the token could not be refreshed: ${error?.message ?? "unknown"}) — run \`kiso login ${providerId}\``);
49
+ }
31
50
  const written = refreshed;
32
51
  // R2a — THE WRITE-BACK MAY NOT RESURRECT WHAT THE HUMAN REMOVED.
33
52
  //
package/dist/chat.d.ts CHANGED
@@ -87,10 +87,9 @@ export declare function statedContextWindow(of?: {
87
87
  /** CW-1: a window as the displays write it — `1M`, `1.05M`, `272K`,
88
88
  * `1,048,576` (a figure that is not round is shown whole). */
89
89
  export declare function windowLabel(tokens: number): string;
90
- /** CW-1: the source, said. The short form rides a /model row; the long
91
- * form is /status's. Only the model step is marked in the short form —
92
- * it is the one figure no row states for this endpoint. */
93
- export declare function windowSourceNote(w: StatedWindow | null, form: "short" | "long"): string;
90
+ /** CW-1: the window and its source, said — /status's line (0.40.6: the
91
+ * short form that rode each /model row is gone with its caller). */
92
+ export declare function windowSourceNote(w: StatedWindow | null): string;
94
93
  export declare function contextWindowTokens(of?: {
95
94
  readonly model: string;
96
95
  readonly baseUrl?: string;
package/dist/chat.js CHANGED
@@ -122,15 +122,12 @@ export function windowLabel(tokens) {
122
122
  return `${tokens / 1000}K`;
123
123
  return tokens.toLocaleString("en-US");
124
124
  }
125
- /** CW-1: the source, said. The short form rides a /model row; the long
126
- * form is /status's. Only the model step is marked in the short form —
127
- * it is the one figure no row states for this endpoint. */
128
- export function windowSourceNote(w, form) {
125
+ /** CW-1: the window and its source, said — /status's line (0.40.6: the
126
+ * short form that rode each /model row is gone with its caller). */
127
+ export function windowSourceNote(w) {
129
128
  if (w === null)
130
- return form === "short" ? "ctx ?" : `window unknown — compaction assumes ${windowLabel(DEFAULT_CONTEXT_WINDOW)}; set contextWindow on the profile to state it`;
129
+ return `window unknown — compaction assumes ${windowLabel(DEFAULT_CONTEXT_WINDOW)}; set contextWindow on the profile to state it`;
131
130
  const size = windowLabel(w.tokens);
132
- if (form === "short")
133
- return w.source === "model" ? `ctx ${size} inferred` : `ctx ${size}`;
134
131
  switch (w.source) {
135
132
  case "set":
136
133
  return `window ${size}, as you set it`;
package/dist/config.d.ts CHANGED
@@ -118,6 +118,13 @@ export interface KisoConfig {
118
118
  * in the child's worktree. A model never supplies a command; it names
119
119
  * one of these. `{ "test": "npm test", "lint": "npm run lint" }`. */
120
120
  readonly checks?: Readonly<Record<string, string>>;
121
+ /** CS-1 (0.40.7): the evaluator scripts a delegated task may name —
122
+ * absolute paths, `["/abs/path/to/evaluate.sh"]`. The PARENT runs the
123
+ * one a task names with the child's worktree as its argument. A path
124
+ * the user did not list is refused: the model chooses the task, so an
125
+ * unlisted path could be an interpreter running code the child wrote.
126
+ * Same layers as `checks`. */
127
+ readonly evaluators?: readonly string[];
121
128
  }
122
129
  /** The resolved, merged config — project wins over user, both validated. */
123
130
  export interface ResolvedConfig {
@@ -145,7 +152,8 @@ export type ProfileAuth = {
145
152
  readonly source: "store" | "env" | "none";
146
153
  } | {
147
154
  readonly type: "oauth";
148
- readonly providerId: string;
155
+ readonly providerId: string; /** the access token's expiry (epoch ms) — past it, the next use renews */
156
+ readonly expires?: number;
149
157
  };
150
158
  /** Where a profile's sign-in comes from, or why it has none. The sign-in
151
159
  * plan's resolve rule: a stored credential OWNS the provider (an
package/dist/config.js CHANGED
@@ -32,7 +32,7 @@ import { readFileSync, statSync } from "node:fs";
32
32
  import { homedir } from "node:os";
33
33
  import { join } from "node:path";
34
34
  import { kisoHome } from "./state.js";
35
- import { AuthError, getCredential, providerIdOf } from "./auth/credentials.js";
35
+ import { AuthError, effectiveBaseUrl, endpointCredentialId, getCredential, providerIdOf } from "./auth/credentials.js";
36
36
  import { BUILTIN_MANIFESTS } from "@vincemakes/kiso-runtime/internal";
37
37
  /** Header names a profile may not set: credentials (a key never lives in the
38
38
  * config file) and the framing the adapter owns. */
@@ -130,6 +130,15 @@ export function parseConfig(text, source) {
130
130
  }
131
131
  out.checks = obj.checks;
132
132
  }
133
+ if (obj.evaluators !== undefined) {
134
+ if (!Array.isArray(obj.evaluators))
135
+ fail("evaluators", "expected a list of absolute paths to evaluator scripts");
136
+ for (const path of obj.evaluators) {
137
+ if (typeof path !== "string" || !path.startsWith("/"))
138
+ fail("evaluators", `expected an absolute path, got ${JSON.stringify(path)}`);
139
+ }
140
+ out.evaluators = obj.evaluators;
141
+ }
133
142
  if (obj.model !== undefined) {
134
143
  if (typeof obj.model !== "string" || obj.model === "")
135
144
  fail("model", "expected a profile name or provider/model string");
@@ -253,6 +262,8 @@ export function mergeConfigs(user, project) {
253
262
  ...(p.projectTrust !== undefined ? { projectTrust: p.projectTrust } : {}),
254
263
  // DT-1a: checks merge per name — a (trusted) project's check wins over the user's
255
264
  ...(u.checks !== undefined || p.checks !== undefined ? { checks: { ...(u.checks ?? {}), ...(p.checks ?? {}) } } : {}),
265
+ // CS-1: the evaluator lists join — either layer's script may be named
266
+ ...(u.evaluators !== undefined || p.evaluators !== undefined ? { evaluators: [...(u.evaluators ?? []), ...(p.evaluators ?? [])] } : {}),
256
267
  };
257
268
  }
258
269
  /** Where a profile's sign-in comes from, or why it has none. The sign-in
@@ -280,8 +291,14 @@ export function authForProfile(name, p) {
280
291
  if (stored !== undefined) {
281
292
  if (stored.type === "api-key")
282
293
  return { type: "api-key", apiKey: stored.key, source: "store" };
283
- if (p.kind === "openai-responses")
284
- return { type: "oauth", providerId };
294
+ if (p.kind === "openai-responses") {
295
+ // 0.40.7: a sign-in whose renewal the endpoint REFUSED is over —
296
+ // unavailable here, where /model reads it, not first at a turn
297
+ if (stored.refreshRejectedAt !== undefined) {
298
+ throw new ConfigError(`model ${name}: unavailable — the ${providerId} sign-in was refused when kiso tried to renew it: run \`kiso login ${providerId}\``);
299
+ }
300
+ return { type: "oauth", providerId, expires: stored.expires };
301
+ }
285
302
  throw new ConfigError(`model ${name}: signed in to ${providerId} with OAuth, but this profile's adapter needs an API key — run \`kiso login ${providerId}\` with a key, or \`kiso logout ${providerId}\` to use the env var ${p.apiKeyEnv ?? "(none configured)"}`);
286
303
  }
287
304
  }
@@ -294,12 +311,34 @@ export function authForProfile(name, p) {
294
311
  if (providerId !== null && !manifestTakesKey(providerId)) {
295
312
  throw new ConfigError(`model ${name}: unavailable — not signed in: run \`kiso login ${providerId}\` (this provider takes no API key)`);
296
313
  }
314
+ // 0.40.6: a GATEWAY's own key, stored for exactly this profile's origin
315
+ // (`kiso login --endpoint <url>`). The rule a vendor credential follows:
316
+ // stored first, the env var only when nothing is stored — and it goes to
317
+ // that origin alone (credentials.ts endpointCredentialId).
318
+ const endpointId = providerId === null ? endpointCredentialId(effectiveBaseUrl(p.kind, p.baseUrl)) : null;
319
+ if (endpointId !== null) {
320
+ let stored;
321
+ try {
322
+ stored = getCredential(endpointId);
323
+ }
324
+ catch (err) {
325
+ if (err instanceof AuthError)
326
+ throw new ConfigError(`model ${name}: ${err.message}`);
327
+ throw err;
328
+ }
329
+ if (stored?.type === "api-key")
330
+ return { type: "api-key", apiKey: stored.key, source: "store" };
331
+ }
297
332
  if (p.apiKeyEnv === undefined)
298
333
  return { type: "api-key", apiKey: "none", source: "none" };
299
334
  const fromEnv = process.env[p.apiKeyEnv];
300
335
  if (fromEnv !== undefined)
301
336
  return { type: "api-key", apiKey: fromEnv, source: "env" };
302
- const hint = providerId !== null ? `run \`kiso login ${providerId}\` or set the env var ${p.apiKeyEnv}` : `set the env var ${p.apiKeyEnv}`;
337
+ const hint = providerId !== null
338
+ ? `run \`kiso login ${providerId}\` or set the env var ${p.apiKeyEnv}`
339
+ : endpointId !== null
340
+ ? `set the env var ${p.apiKeyEnv}, or run \`kiso login --endpoint ${endpointId.slice("endpoint:".length)}\``
341
+ : `set the env var ${p.apiKeyEnv}`;
303
342
  throw new ConfigError(`model ${name}: unavailable — no credential: ${hint} (configs never store keys, only the env-var name)`);
304
343
  }
305
344
  /** OR-2: does the provider's manifest admit an API key (or no auth at all)?
package/dist/dispatch.js CHANGED
@@ -13,6 +13,10 @@ import { agentBaseUrl, currentProfileName, setCurrentProfileName, currentModelNa
13
13
  import { adapterOptionsFor } from "./auth/adapter-options.js";
14
14
  import { profileProviderLabel, providerLabel } from "./provider-label.js";
15
15
  import { installedVersion, versionStatusLine } from "./stale-version.js";
16
+ import { preferences, setPreference } from "./preferences.js";
17
+ import { settingsRows } from "./settings.js";
18
+ import { floorOn, settingsLayers } from "./state.js";
19
+ import { currentGround } from "@vincemakes/kiso-tui-cells/render";
16
20
  import { queuedSwitchLines } from "./state.js";
17
21
  import { contextWindowTokens, microcompactThresholdFor, startStatusSpinner, statedContextWindow, windowSourceNote } from "./chat.js";
18
22
  import { authForProfile, directWriteProfile, profileAvailable, resolveContextWindow, unavailableReason } from "./config.js";
@@ -97,27 +101,37 @@ function effortAxis(p) {
97
101
  : {}),
98
102
  };
99
103
  }
100
- /** What signs a profile in, for the listing: the env var's name, `oauth`
101
- * for a subscription sign-in, or `no key` for an unauthenticated endpoint. */
104
+ /** What signs a profile in, for the listing: `stored key` when a key in
105
+ * auth.json pays (a vendor's, or a gateway's `kiso login --endpoint`),
106
+ * `oauth` for a subscription sign-in, else the env var's name, or `no key`
107
+ * for an unauthenticated endpoint.
108
+ *
109
+ * 0406-F1: this named the env var whenever a profile had one, so a row
110
+ * paid by the stored key read `DEEPSEEK_API_KEY (available)` with that
111
+ * variable unset — and after 0.40.6's endpoint login every gateway row
112
+ * would have named a variable nobody exports any more. */
113
+ /** MP-1 (0.40.7): who a profile's requests are billed to — the endpoint
114
+ * and the credential that pays there (`@api.deepseek.com · stored key`). */
115
+ function payeeOf(p) {
116
+ const host = profileProviderLabel(p.kind, p.baseUrl, p.upstream);
117
+ const who = signInNote(p).replace(/, expired — renews on use$/, "");
118
+ return host === "" ? who : `${host} · ${who}`;
119
+ }
102
120
  function signInNote(p) {
103
121
  try {
104
122
  const auth = authForProfile("?", p);
123
+ // 0.40.7: an access token past its expiry is not a dead sign-in — the
124
+ // next use renews it — but the row says so rather than nothing
105
125
  if (auth.type === "oauth")
106
- return "oauth";
126
+ return auth.expires !== undefined && auth.expires < Date.now() ? "oauth, expired — renews on use" : "oauth";
127
+ if (auth.source === "store")
128
+ return "stored key";
107
129
  }
108
130
  catch {
109
131
  // unavailable — the availability mark says so; the env name still names what would sign it in
110
132
  }
111
133
  return p.apiKeyEnv ?? "no key";
112
134
  }
113
- /** CW-1 (owner, 2026-09-23): a profile's window on its /model row — the
114
- * profile's own stated figure first, then the registry's chain for its
115
- * model, endpoint and upstream; `inferred` marks the one no row states
116
- * for that endpoint. */
117
- function windowNoteOf(p) {
118
- const w = statedContextWindow({ model: p.model, ...(p.baseUrl !== undefined ? { baseUrl: p.baseUrl } : {}), ...(p.upstream !== undefined ? { upstream: p.upstream } : {}) }, { configured: resolveContextWindow(mergedConfig, p) });
119
- return windowSourceNote(w, "short");
120
- }
121
135
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
122
136
  * replay routes through it too — a queued "/last" must never become a
123
137
  * user turn (v2c: the rl lives in main, so lines arrive earlier and the
@@ -383,18 +397,15 @@ export function dispatch(line, ctx) {
383
397
  return;
384
398
  }
385
399
  if (trimmed === "\x14think") {
386
- // §2.3 — ctrl+t folds the committed thinking blocks, and folds them
387
- // back. DC-50's mechanism, not a second one: one boolean, then the
388
- // session is reprinted, so the blocks already on screen obey the
389
- // switch rather than only the next ones.
390
- //
391
- // The folded row is `foldThinking`'s, which is what the PIPE writes
392
- // — so thinking has two renderings in the product and not three,
393
- // and the pipe's byte-identity gate is this row's gate too.
394
- //
395
- // The live `thinking…` placeholder is NOT touched: it belongs to
396
- // the live region, and this switch is about committed blocks.
400
+ // §2.3 / 0.40.6 — ctrl+t hides thinking (one italic line per block,
401
+ // the open one included) and shows it again. DC-50's mechanism: one
402
+ // boolean, then the session is reprinted, so the blocks already on
403
+ // screen obey the switch. The choice is REMEMBERED (preferences.json)
404
+ // — the owner, 2026-09-23, after the reference implementation's
405
+ // persisted toggle; shown stays the default. The pipe keeps its own
406
+ // one-line fold whatever the choice.
397
407
  body.toggleThinking();
408
+ setPreference("thinking", body.thinkingHidden() ? "hidden" : "shown");
398
409
  ctx.input.prompt();
399
410
  return;
400
411
  }
@@ -487,6 +498,31 @@ export function dispatch(line, ctx) {
487
498
  });
488
499
  return;
489
500
  }
501
+ if (trimmed === "/settings") {
502
+ // 0.40.6 — what kiso runs with, each value's layer, and how to change
503
+ // it (settings.ts). Read-only: the config file stays the human's.
504
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
505
+ const w = statedContextWindow();
506
+ const profile = currentProfileName;
507
+ bodyLog(settingsRows({
508
+ user: settingsLayers.user,
509
+ project: settingsLayers.project,
510
+ env: process.env,
511
+ ...(settingsLayers.modeFlag !== undefined ? { modeFlag: settingsLayers.modeFlag } : {}),
512
+ ...(settingsLayers.modelFlag !== undefined ? { modelFlag: settingsLayers.modelFlag } : {}),
513
+ mode: getMode(),
514
+ model: { label: `${agentModel}${providerLabel(agentBaseUrl, upstreamOf(agentBaseUrl))}${profile === null ? "" : ` · profile ${profile}`}`, profile, switched: settingsLayers.modelSwitched },
515
+ ground: currentGround(),
516
+ floorOn,
517
+ window: windowSourceNote(w).replace(/^window /, ""),
518
+ thinkingHidden: body.thinkingHidden(),
519
+ thinkingRemembered: preferences().thinking !== undefined,
520
+ version: versionStatusLine(installedVersion(), VERSION).replace(/^version /, ""),
521
+ }).join("\n\n"), "words");
522
+ ctx.input.prompt();
523
+ });
524
+ return;
525
+ }
490
526
  if (trimmed === "/status") {
491
527
  // B area: session id, durable event count, and the ~ context
492
528
  // estimate — all read straight from the live session, nothing
@@ -499,7 +535,7 @@ export function dispatch(line, ctx) {
499
535
  // CW-1: the percentage names its denominator and who stated it — a
500
536
  // window inferred from the model reads differently from one the
501
537
  // registry states for this endpoint.
502
- bodyLog(`ctx ${ctxPct} · ${windowSourceNote(statedContextWindow(), "long")}`);
538
+ bodyLog(`ctx ${ctxPct} · ${windowSourceNote(statedContextWindow())}`);
503
539
  // The owner, 2026-09-21: /status is where "what am I actually
504
540
  // running on" is answered, so the identity is spelled out HERE —
505
541
  // the model, the host the request goes to, the profile it came
@@ -546,6 +582,8 @@ export function dispatch(line, ctx) {
546
582
  ctx.input.panelAsk(modePickView({
547
583
  header: `mode — current: ${current}`,
548
584
  options: OFFERED_MODES.map((name) => ({ label: name, note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
585
+ // MP-1 (0.40.7): the cursor opens on the tier in force
586
+ ...(OFFERED_MODES.indexOf(current) >= 0 ? { initial: OFFERED_MODES.indexOf(current) } : {}),
549
587
  },
550
588
  // DC-12 (design §4): a panel WAITING ON A HUMAN says ❯.
551
589
  ctx.isRunning() ? "❯ run paused" : `▸ ${current}`), (v) => resolve(v.action === "picked" ? v.result : null));
@@ -622,9 +660,11 @@ export function dispatch(line, ctx) {
622
660
  // profile key is what `/model <name>` takes, not what a chooser
623
661
  // reading a list needs. What remains is what the chooser CANNOT
624
662
  // infer: availability, and which one is live.
625
- // CW-1: the window rides LAST — availability and the live mark
626
- // are what a narrow row must keep.
627
- const marks = [...(profileAvailable(profile) ? [] : ["unavailable"]), ...(name === currentProfileName ? ["current"] : []), windowNoteOf(profile)];
663
+ // 0.40.6 (the owner, 2026-09-23: "who told you to write ctx"):
664
+ // the window rode here in 0.40.3–0.40.5 and cut `unavailable`
665
+ // to `unavailabl…` on the owner's rows. It lives on /status and
666
+ // /settings, where there is room to say it and its source.
667
+ const marks = [...(profileAvailable(profile) ? [] : ["unavailable"]), ...(name === currentProfileName ? ["current"] : [])];
628
668
  const host = profileProviderLabel(profile.kind, profile.baseUrl, profile.upstream);
629
669
  return { label: `${profile.kind}/${profile.model}${host === "" ? "" : ` ${host}`}`, note: marks.join(" · "), ...effortAxis(profile) };
630
670
  }),
@@ -633,6 +673,9 @@ export function dispatch(line, ctx) {
633
673
  // "openai/…" hint failed with "no such model profile"
634
674
  // on exactly the fresh-install path that shows it.
635
675
  typeHint: names.length === 0 ? "type provider/model directly (e.g. openai-compat/deepseek-reasoner)" : "type provider/model directly",
676
+ // MP-1 (0.40.7): the cursor opens on the session's own
677
+ // profile — opening on row 0 plus one Enter changed who pays
678
+ ...(currentProfileName !== null && names.indexOf(currentProfileName) >= 0 ? { initial: names.indexOf(currentProfileName) } : {}),
636
679
  // the zero-profile copy is TODAY'S, verbatim: the
637
680
  // user who sees it is exactly the user who needs
638
681
  // the path spelled out
@@ -676,7 +719,7 @@ export function dispatch(line, ctx) {
676
719
  for (const name of names) {
677
720
  const p = configModels[name];
678
721
  const hostOf = profileProviderLabel(p.kind, p.baseUrl, p.upstream);
679
- bodyLog(` ${name} → ${p.kind}/${p.model}${hostOf === "" ? "" : ` ${hostOf}`} · ${signInNote(p)} ${profileAvailable(p) ? "(available)" : "(unavailable)"} · ${effortNote(p)} · ${windowNoteOf(p)}`);
722
+ bodyLog(` ${name} → ${p.kind}/${p.model}${hostOf === "" ? "" : ` ${hostOf}`} · ${signInNote(p)} ${profileAvailable(p) ? "(available)" : "(unavailable)"} · ${effortNote(p)}`);
680
723
  }
681
724
  }
682
725
  bodyLog("switch with /model <profile-name|provider/model>");
@@ -768,11 +811,16 @@ export function dispatch(line, ctx) {
768
811
  // display — a direct provider/model has none.
769
812
  profileName: direct === null ? profName : null,
770
813
  };
814
+ // MP-1 (0.40.7): who pays BEFORE the switch, read while the
815
+ // session still names the outgoing profile
816
+ const prevProfile = currentProfileName !== null ? configModels[currentProfileName] : undefined;
817
+ const payeeBefore = prevProfile !== undefined ? payeeOf(prevProfile) : null;
771
818
  ctx.session.setModelBinding(binding);
772
819
  setLastBinding(binding);
773
820
  setAgentModel(profile.model, profile.baseUrl);
774
821
  setCurrentModelName(arg);
775
822
  setCurrentProfileName(direct === null ? null : profName);
823
+ settingsLayers.modelSwitched = true;
776
824
  // §2.5: the ONE source a reload reads for the model — a
777
825
  // switch made here must survive the rebuild.
778
826
  setModelChoice(arg);
@@ -793,6 +841,13 @@ export function dispatch(line, ctx) {
793
841
  // model beside the previous model's figure.
794
842
  ctx.modelSwitched();
795
843
  body.notice(`model → ${profName} (${profile.model}${providerLabel(profile.baseUrl, profile.upstream) === "" ? "" : ` ${providerLabel(profile.baseUrl, profile.upstream)}`}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
844
+ // MP-1 (0.40.7): when the switch changes WHO PAYS — another
845
+ // endpoint, or another credential for it — say so, once,
846
+ // under the switch line. A switch between two models on the
847
+ // same account says nothing more.
848
+ const payeeAfter = payeeOf(profile);
849
+ if (payeeBefore !== null && payeeBefore !== payeeAfter)
850
+ body.notice(`paid by ${payeeAfter} from the next turn — was ${payeeBefore}`);
796
851
  }
797
852
  }
798
853
  }
package/dist/index.js CHANGED
@@ -53,6 +53,9 @@ import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
53
53
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
54
54
  import { chat, compactionDiscardedNotice, contextWindowTokens, displayCtxRatio, knownContextWindow, microcompactThresholdFor, statusModelLabel, unknownWindowNotice, windowLearnedNotice } from "./chat.js";
55
55
  import { recordLearnedWindow, useLearnedWindows } from "./learned-windows.js";
56
+ import { preferences, usePreferences } from "./preferences.js";
57
+ import { sweepStaleMergeDirs } from "./temp-sweep.js";
58
+ import { settingsLayers } from "./state.js";
56
59
  import { providerHost } from "./provider-label.js";
57
60
  import { adapterOptionsFor } from "./auth/adapter-options.js";
58
61
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
@@ -66,6 +69,8 @@ import { armByteTrace } from "./byte-trace.js";
66
69
  import { tmpdir, homedir } from "node:os";
67
70
  import { clipboardImage } from "./clipboard.js";
68
71
  import { cardsFromListings } from "./session-cards.js";
72
+ /** 0.40.7: the stale-merge-directory sweep runs once per process. */
73
+ let tempSwept = false;
69
74
  // The moved exports stay reachable from this entry — the test imports
70
75
  // (project-trust, coding-agent) never change (B4: zero assertion changes).
71
76
  export { applyProjectMerges } from "./trust-ui.js";
@@ -759,6 +764,10 @@ async function makeAgent(sessionId, input, modelFlag) {
759
764
  const userCfg = loadUserConfig();
760
765
  const projectCfg = loadProjectConfig(process.cwd(), project !== null);
761
766
  const merged = mergeConfigs(userCfg, projectCfg);
767
+ // 0.40.6: /settings names each value's layer from these
768
+ settingsLayers.user = userCfg;
769
+ settingsLayers.project = projectCfg;
770
+ settingsLayers.modelFlag = modelFlag;
762
771
  const secretEnvNames = secretEnvNamesOf(merged.models ?? {});
763
772
  const builtIn = await builtInLayer(user, proj, input !== undefined && process.stdin.isTTY ? askUi(input) : undefined, secretEnvNames);
764
773
  setExtensionLists(builtIn, user, proj, [...builtIn, ...user, ...proj]);
@@ -776,11 +785,17 @@ async function makeAgent(sessionId, input, modelFlag) {
776
785
  // channel and not an exported KISO_SESSIONS_DIR: a variable in
777
786
  // process.env would reach every shell child, and a kiso started from a
778
787
  // shell tool would then write into this project's folder.
779
- process.env.KISO_DELEGATION_CONFIG_JSON = JSON.stringify({ checks: merged.checks ?? {}, profiles: Object.keys(merged.models ?? {}), sessionsDir: sessionsDir() });
788
+ process.env.KISO_DELEGATION_CONFIG_JSON = JSON.stringify({ checks: merged.checks ?? {}, evaluators: merged.evaluators ?? [], profiles: Object.keys(merged.models ?? {}), sessionsDir: sessionsDir() });
780
789
  setConfigModels(merged.models ?? {});
781
790
  // CW-1 batch 2: the windows endpoints stated by refusing — read before the
782
791
  // first window is asked for (the unknown-window notice below).
783
792
  useLearnedWindows();
793
+ // 0.40.7: the merge directories a killed kiso left in the temp dir —
794
+ // once per process, never this process's own (temp-sweep.ts)
795
+ if (!tempSwept) {
796
+ tempSwept = true;
797
+ sweepStaleMergeDirs();
798
+ }
784
799
  const resolved = resolveModel(modelFlag, merged);
785
800
  // AFTER the model resolves: the window a PROFILE states is about that
786
801
  // profile's model, so it cannot be read before we know which profile is
@@ -1286,7 +1301,7 @@ async function reloadAgent(old, id, input, announce = true) {
1286
1301
  rmSync(p, { recursive: true, force: true });
1287
1302
  }
1288
1303
  catch {
1289
- // best-effort — the temp dir would be reaped by the OS
1304
+ // best-effort — a survivor is removed by a later startup's sweep (temp-sweep.ts) once this process is gone
1290
1305
  }
1291
1306
  }
1292
1307
  mergedTempPaths.push(...oldTemps);
@@ -1315,7 +1330,7 @@ async function reloadAgent(old, id, input, announce = true) {
1315
1330
  rmSync(p, { recursive: true, force: true });
1316
1331
  }
1317
1332
  catch {
1318
- // best-effort — the temp dir would be reaped by the OS
1333
+ // best-effort — a survivor is removed by a later startup's sweep (temp-sweep.ts) once this process is gone
1319
1334
  }
1320
1335
  }
1321
1336
  if (announce)
@@ -1643,6 +1658,7 @@ async function main() {
1643
1658
  process.exit(2);
1644
1659
  }
1645
1660
  setMode(m);
1661
+ settingsLayers.modeFlag = m;
1646
1662
  args.splice(modeFlag, 2);
1647
1663
  }
1648
1664
  else {
@@ -1790,6 +1806,10 @@ async function main() {
1790
1806
  editCol: () => dock.editCol(),
1791
1807
  onDock: () => dock.redraw(), // v2d-B: the freeze scrolls the dock up — re-pin it
1792
1808
  }));
1809
+ // 0.40.6: the choices kiso remembers (preferences.json) — today ctrl+t's
1810
+ // thinking display, restored before the first block can be drawn.
1811
+ usePreferences();
1812
+ body.setThinkingHidden(preferences().thinking === "hidden");
1793
1813
  // REL-0152-D11: pasting an image sends no bytes, so an empty paste is
1794
1814
  // the signal to go and look at the clipboard. What comes back is a
1795
1815
  // PATH, which the turn's attachment scan then picks up exactly as it
@@ -2001,8 +2021,33 @@ async function main() {
2001
2021
  // `login <provider>` reads the key from a hidden prompt on a TTY
2002
2022
  // or from stdin otherwise (never from an argument — shell history);
2003
2023
  // `logout <provider>` deletes; `auth` lists, keys masked.
2004
- const { KNOWN_PROVIDERS, OAUTH_PROVIDERS, authPath, deleteCredential, maskSecret, readAuthFile, setCredential } = await import("./auth/credentials.js");
2024
+ const { KNOWN_PROVIDERS, OAUTH_PROVIDERS, authPath, deleteCredential, endpointCredentialId, maskSecret, providerIdOf, readAuthFile, setCredential } = await import("./auth/credentials.js");
2005
2025
  const provider = arg;
2026
+ if (provider === "--endpoint" && command !== "auth") {
2027
+ // 0.40.6: a gateway's own key, stored for its ORIGIN and sent
2028
+ // there alone (credentials.ts endpointCredentialId) — so a
2029
+ // gateway profile starts from plain `kiso`, no env var on the
2030
+ // command line. The key still never comes from an argument.
2031
+ const url = args[2];
2032
+ const id = endpointCredentialId(url);
2033
+ if (id === null)
2034
+ throw new CliUsageError(`kiso ${command} --endpoint <url> — an http(s) URL, e.g. https://gateway.example/v1`);
2035
+ const origin = id.slice("endpoint:".length);
2036
+ const vendor = ["openai-compat", "anthropic", "openai-responses"].map((k) => providerIdOf(k, url)).find((v) => v !== null);
2037
+ if (vendor !== undefined && vendor !== null)
2038
+ throw new CliUsageError(`${origin} is ${vendor}'s own endpoint — run \`kiso ${command} ${vendor}\``);
2039
+ if (command === "logout") {
2040
+ const had = deleteCredential(id);
2041
+ process.stdout.write(had ? `removed the key for ${origin}\n` : `nothing stored for ${origin}\n`);
2042
+ break;
2043
+ }
2044
+ const key = (await readSecret(`API key for ${origin}: `)).trim();
2045
+ if (key === "")
2046
+ throw new CliUsageError(`kiso login --endpoint ${origin}: no key given`);
2047
+ setCredential(id, { type: "api-key", key, savedAt: Date.now() });
2048
+ process.stdout.write(`stored an API key for ${origin} (${maskSecret(key)}) in ${authPath()} — sent to that origin only; every profile whose baseUrl is on it uses this key before its env var\n`);
2049
+ break;
2050
+ }
2006
2051
  if (command === "auth") {
2007
2052
  const file = readAuthFile();
2008
2053
  const rows = Object.entries(file.credentials);
@@ -2011,7 +2056,7 @@ async function main() {
2011
2056
  if (cred.type === "api-key")
2012
2057
  lines.push(` ${id.padEnd(10)} api-key ${maskSecret(cred.key)} saved ${new Date(cred.savedAt).toISOString().slice(0, 10)}`);
2013
2058
  else
2014
- lines.push(` ${id.padEnd(10)} oauth ${cred.accountId ?? ""} expires ${new Date(cred.expires).toISOString()}${cred.expires < Date.now() ? " (expired)" : ""}`);
2059
+ lines.push(` ${id.padEnd(10)} oauth ${cred.accountId ?? ""} expires ${new Date(cred.expires).toISOString()}${cred.refreshRejectedAt !== undefined ? ` (renewal refused — run kiso login ${id})` : cred.expires < Date.now() ? " (expired — renews on use)" : ""}`);
2015
2060
  }
2016
2061
  const envs = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].filter((k) => process.env[k] !== undefined);
2017
2062
  lines.push(`env vars set: ${envs.length ? envs.join(", ") : "none"} (a stored credential owns its provider; env applies only when nothing is stored)`);
@@ -2019,7 +2064,7 @@ async function main() {
2019
2064
  break;
2020
2065
  }
2021
2066
  if (provider === undefined || !KNOWN_PROVIDERS.includes(provider)) {
2022
- throw new CliUsageError(`kiso ${command} <provider> — one of: ${KNOWN_PROVIDERS.join(", ")}`);
2067
+ throw new CliUsageError(`kiso ${command} <provider> — one of: ${KNOWN_PROVIDERS.join(", ")}; or kiso ${command} --endpoint <url> for a gateway`);
2023
2068
  }
2024
2069
  if (command === "logout") {
2025
2070
  const had = deleteCredential(provider);
@@ -2096,7 +2141,8 @@ async function main() {
2096
2141
  " kiso sessions [--all|--current] list durable sessions (a terminal shows this workspace's by default)\n" +
2097
2142
  " kiso login <provider> anthropic|openai|deepseek|zai: store an API key (hidden prompt, or stdin when piped);\n" +
2098
2143
  " chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
2099
- " kiso logout <provider> remove the stored credential\n" +
2144
+ " kiso login --endpoint <url> a gateway: store its API key for that URL's origin only\n" +
2145
+ " kiso logout <provider> remove the stored credential (or --endpoint <url>)\n" +
2100
2146
  " kiso auth list stored credentials (keys masked)\n" +
2101
2147
  " kiso update install the latest release (npm i -g @vincemakes/kiso-code@latest)\n" +
2102
2148
  " kiso help this help\n\n" +
@@ -2109,7 +2155,7 @@ async function main() {
2109
2155
  " OPENAI_API_KEY OpenAI-compatible (OPENAI_MODEL, default gpt-4o;\n" +
2110
2156
  " OPENAI_BASE_URL for DeepSeek/compat endpoints) — checked first\n" +
2111
2157
  " ANTHROPIC_API_KEY Anthropic (ANTHROPIC_MODEL, default claude-sonnet-5)\n" +
2112
- " ~/.kiso/config.json named model profiles (keys stay in env vars; see the README)\n");
2158
+ " ~/.kiso/config.json named model profiles (keys stay in env vars or kiso login; see the README)\n");
2113
2159
  break;
2114
2160
  }
2115
2161
  case undefined:
@@ -2161,7 +2207,7 @@ async function main() {
2161
2207
  rmSync(p, { recursive: true, force: true });
2162
2208
  }
2163
2209
  catch {
2164
- // best-effort — the temp dir would be reaped by the OS
2210
+ // best-effort — a survivor is removed by a later startup's sweep (temp-sweep.ts) once this process is gone
2165
2211
  }
2166
2212
  }
2167
2213
  }
@@ -0,0 +1,14 @@
1
+ export interface Preferences {
2
+ /** ctrl+t: the thinking blocks as one italic line each. Absent = shown. */
3
+ readonly thinking?: "shown" | "hidden";
4
+ }
5
+ export declare function preferencesPath(home?: string): string;
6
+ /** Startup: read the file, and keep choices made from here on in it. */
7
+ export declare function usePreferences(path?: string): void;
8
+ /** The remembered choices; empty before startup names the file. */
9
+ export declare function preferences(): Preferences;
10
+ /** Remember a choice. Returns false when no file was named. The file is
11
+ * re-read before the write, so another process's choice is kept. */
12
+ export declare function setPreference<K extends keyof Preferences>(key: K, value: NonNullable<Preferences[K]>): boolean;
13
+ /** Tests: back to the state before startup. */
14
+ export declare function resetPreferences(): void;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 0.40.6 — the choices kiso remembers for you, in
3
+ * `<KISO_HOME>/preferences.json`.
4
+ *
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
8
+ * (0600, tmp + rename), and read back at start-up. A missing or unreadable
9
+ * file is an empty one; an unknown value is ignored rather than guessed.
10
+ *
11
+ * Nothing is read until the CLI's startup names the file
12
+ * (`usePreferences`) — the same rule as learned-windows.ts, so in-process
13
+ * tests never read a home directory.
14
+ */
15
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { kisoHome } from "./state.js";
18
+ let prefs = null;
19
+ let prefsPath = null;
20
+ export function preferencesPath(home = kisoHome()) {
21
+ return join(home, "preferences.json");
22
+ }
23
+ function read(path) {
24
+ try {
25
+ const raw = JSON.parse(readFileSync(path, "utf8"));
26
+ return raw.thinking === "shown" || raw.thinking === "hidden" ? { thinking: raw.thinking } : {};
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ /** Startup: read the file, and keep choices made from here on in it. */
33
+ export function usePreferences(path = preferencesPath()) {
34
+ prefsPath = path;
35
+ prefs = read(path);
36
+ }
37
+ /** The remembered choices; empty before startup names the file. */
38
+ export function preferences() {
39
+ return prefs ?? {};
40
+ }
41
+ /** Remember a choice. Returns false when no file was named. The file is
42
+ * re-read before the write, so another process's choice is kept. */
43
+ export function setPreference(key, value) {
44
+ const path = prefsPath;
45
+ if (path === null)
46
+ return false;
47
+ const next = { ...read(path), [key]: value };
48
+ prefs = next;
49
+ try {
50
+ mkdirSync(dirname(path), { recursive: true });
51
+ const tmp = `${path}.${process.pid}.tmp`;
52
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
53
+ renameSync(tmp, path);
54
+ }
55
+ catch {
56
+ // an unwritable home: the choice still holds for this process
57
+ }
58
+ return true;
59
+ }
60
+ /** Tests: back to the state before startup. */
61
+ export function resetPreferences() {
62
+ prefs = null;
63
+ prefsPath = null;
64
+ }
@@ -33,7 +33,7 @@
33
33
  import { statSync } from "node:fs";
34
34
  import { homedir } from "node:os";
35
35
  import { basename, isAbsolute, join, relative } from "node:path";
36
- import { isCredentialName } from "@vincemakes/kiso-tools-node";
36
+ import { isCredentialName, isCredentialPath } from "@vincemakes/kiso-tools-node";
37
37
  import { getMode } from "./mode.js";
38
38
  import { HOME_SUBTREES, parseShell, realCase, resolveShellPath } from "./shell-words.js";
39
39
  // ── the predicates ──────────────────────────────────────────────────────
@@ -55,15 +55,11 @@ function scope(ctx, word) {
55
55
  }
56
56
  return null;
57
57
  }
58
- /** B4: credentials by the shell's own list — names the search corpus's
59
- * rule does not cover, on the canonical path. (The corpus rule itself,
60
- * shared with read_file and search_text, is not moved before the freeze:
61
- * finding RO-F4, 0.41.0.) */
62
- const SHELL_CREDENTIAL_NAMES = new Set([".npmrc", ".pypirc", ".git-credentials", ".htpasswd"]);
63
- const SHELL_CREDENTIAL_PATHS = ["/.aws/credentials", "/.config/gh/hosts.yml", "/.docker/config.json", "/.kube/config"];
58
+ /** B4, and since RO-F4 (0.40.7) the search corpus's own set: the shell and
59
+ * the file tools ask ONE rule — names (`isCredentialName`) and the
60
+ * directory-scoped paths (`isCredentialPath`) — on the canonical path. */
64
61
  function shellCredential(canonical) {
65
- const folded = canonical.toLowerCase();
66
- return SHELL_CREDENTIAL_NAMES.has(basename(folded)) || SHELL_CREDENTIAL_PATHS.some((s) => folded.endsWith(s));
62
+ return isCredentialName(basename(canonical).toLowerCase()) || isCredentialPath(canonical);
67
63
  }
68
64
  function content(ctx, word) {
69
65
  const s = scope(ctx, word);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 0.40.6 — `/settings`: what kiso is running with, where each value came
3
+ * from, and how to change it.
4
+ *
5
+ * The owner, 2026-09-23, asked whether kiso has a help for what can be
6
+ * configured. `/help` lists the commands and `?` the keys; the settings
7
+ * lived only in docs/configuration.md and in files the user had to open.
8
+ *
9
+ * READ-ONLY by design: the config file stays the human's. The layers are
10
+ * the documented precedence — flag > env > project config > user config >
11
+ * default — and a value that no layer explains was set in this session
12
+ * (`/mode`, `/model`). Pure: every input is passed in, so each row is
13
+ * testable without a session.
14
+ */
15
+ import type { KisoConfig } from "./config.js";
16
+ export interface SettingsInput {
17
+ readonly user: KisoConfig | null;
18
+ readonly project: KisoConfig | null;
19
+ readonly env: Readonly<Record<string, string | undefined>>;
20
+ /** the --mode / --model values on the command line, when given */
21
+ readonly modeFlag?: string;
22
+ readonly modelFlag?: string;
23
+ /** what is live now */
24
+ readonly mode: string;
25
+ readonly model: {
26
+ readonly label: string;
27
+ readonly profile: string | null;
28
+ readonly switched: boolean;
29
+ };
30
+ readonly ground: string;
31
+ readonly floorOn: boolean;
32
+ readonly window: string;
33
+ readonly thinkingHidden: boolean;
34
+ readonly thinkingRemembered: boolean;
35
+ readonly version: string;
36
+ }
37
+ export declare function settingsRows(i: SettingsInput): string[];
@@ -0,0 +1,79 @@
1
+ function modeRow(i) {
2
+ const change = "/mode or shift+tab; \"mode\" in ~/.kiso/config.json";
3
+ const from = i.modeFlag === i.mode
4
+ ? "--mode"
5
+ : i.env.KISO_MODE === i.mode
6
+ ? "env KISO_MODE"
7
+ : i.project?.mode === i.mode
8
+ ? "project config"
9
+ : i.user?.mode === i.mode
10
+ ? "user config"
11
+ : i.mode === "default"
12
+ ? "default"
13
+ : "set in this session";
14
+ return { name: "mode", value: i.mode, from, change };
15
+ }
16
+ function modelRow(i) {
17
+ const change = "/model; \"model\" in ~/.kiso/config.json";
18
+ const p = i.model.profile;
19
+ const from = i.model.switched
20
+ ? "set in this session"
21
+ : i.modelFlag !== undefined
22
+ ? "--model"
23
+ : p !== null && i.project?.model === p
24
+ ? "project config"
25
+ : p !== null && i.user?.model === p
26
+ ? "user config"
27
+ : i.env.OPENAI_API_KEY !== undefined && p === null
28
+ ? "env OPENAI_API_KEY"
29
+ : "default";
30
+ return { name: "model", value: i.model.label, from, change };
31
+ }
32
+ function themeRow(i) {
33
+ const change = "\"theme\": \"dark\" | \"light\" in ~/.kiso/config.json; KISO_THEME for one run";
34
+ if (i.env.KISO_THEME !== undefined)
35
+ return { name: "theme", value: i.env.KISO_THEME, from: "env KISO_THEME", change };
36
+ if (i.user?.theme !== undefined)
37
+ return { name: "theme", value: i.user.theme, from: "user config", change };
38
+ return { name: "theme", value: i.ground, from: "the terminal (detected)", change };
39
+ }
40
+ function layered(i, key) {
41
+ if (i.project?.[key] !== undefined)
42
+ return { value: i.project[key], from: "project config" };
43
+ if (i.user?.[key] !== undefined)
44
+ return { value: i.user[key], from: "user config" };
45
+ return { value: undefined, from: "default" };
46
+ }
47
+ export function settingsRows(i) {
48
+ const trust = layered(i, "projectTrust");
49
+ const auto = i.env.KISO_AUTO_COMPACT !== undefined ? { value: i.env.KISO_AUTO_COMPACT, from: "env KISO_AUTO_COMPACT" } : layered(i, "autoCompact");
50
+ const rows = [
51
+ modelRow(i),
52
+ modeRow(i),
53
+ themeRow(i),
54
+ {
55
+ name: "floor",
56
+ value: i.floorOn ? "on — irrecoverable deletes are refused in every mode" : "off",
57
+ from: i.user?.floor !== undefined ? "user config" : "default",
58
+ change: "\"floor\": \"off\" in ~/.kiso/config.json (user config only)",
59
+ },
60
+ { name: "window", value: i.window, from: "the window chain (see /status)", change: "\"contextWindow\" on the profile; KISO_CONTEXT_WINDOW for one run" },
61
+ { name: "compaction", value: "in-run: past half the window at a phase end, past 80% at once", from: "built in", change: "/compact on demand" },
62
+ {
63
+ name: "auto-compact",
64
+ value: auto.value === undefined ? "off" : typeof auto.value === "object" ? `at ${auto.value.thresholdRatio} of the window, at a run's start` : String(auto.value),
65
+ from: auto.from,
66
+ change: "\"autoCompact\" in config; KISO_AUTO_COMPACT",
67
+ },
68
+ { name: "project trust", value: trust.value ?? "ask", from: trust.from, change: "\"projectTrust\": \"ask\" | \"never\" in config" },
69
+ {
70
+ name: "thinking",
71
+ value: i.thinkingHidden ? "hidden — one line per block" : "shown",
72
+ from: i.thinkingRemembered ? "ctrl+t (remembered)" : "default",
73
+ change: "ctrl+t",
74
+ },
75
+ { name: "version", value: i.version, from: "running", change: "kiso update" },
76
+ ];
77
+ const width = Math.max(...rows.map((r) => r.name.length)) + 2;
78
+ return rows.map((r) => `${r.name.padEnd(width)}${r.value}\n${" ".repeat(width)}from ${r.from} · change: ${r.change}`);
79
+ }
package/dist/state.d.ts CHANGED
@@ -295,6 +295,16 @@ export declare const queuedSwitchLines: string[];
295
295
  /** E1: the extensions loaded by makeAgent — their names feed the banner. */
296
296
  /** 0.40.0: whether the catastrophe floor is on (floor.ts) — the user
297
297
  * config's `floor`, read where the chain is assembled. */
298
+ /** 0.40.6: what /settings needs to name each value's layer — the two config
299
+ * files as read, the command-line flags as given, and whether /model
300
+ * changed the binding in this session. */
301
+ export declare const settingsLayers: {
302
+ user: import("./config.js").KisoConfig | null;
303
+ project: import("./config.js").KisoConfig | null;
304
+ modeFlag: string | undefined;
305
+ modelFlag: string | undefined;
306
+ modelSwitched: boolean;
307
+ };
298
308
  export declare let floorOn: boolean;
299
309
  export declare function setFloorOn(value: boolean): void;
300
310
  /** 0.40.0: the calls a saved allow never carries (protected-writes.ts) —
package/dist/state.js CHANGED
@@ -397,6 +397,10 @@ export const queuedSwitchLines = [];
397
397
  /** E1: the extensions loaded by makeAgent — their names feed the banner. */
398
398
  /** 0.40.0: whether the catastrophe floor is on (floor.ts) — the user
399
399
  * config's `floor`, read where the chain is assembled. */
400
+ /** 0.40.6: what /settings needs to name each value's layer — the two config
401
+ * files as read, the command-line flags as given, and whether /model
402
+ * changed the binding in this session. */
403
+ export const settingsLayers = { user: null, project: null, modeFlag: undefined, modelFlag: undefined, modelSwitched: false };
400
404
  export let floorOn = true;
401
405
  export function setFloorOn(value) {
402
406
  floorOn = value;
@@ -0,0 +1,12 @@
1
+ export declare const LEGACY_MAX_AGE_MS: number;
2
+ /** One startup never spends long here, whatever the temp directory holds. */
3
+ export declare const SWEEP_LIMIT = 200;
4
+ /** The mkdtemp prefix for a merge directory: this process's pid in the name. */
5
+ export declare function mergeDirPrefix(kind: "mcp" | "skills"): string;
6
+ /** Is a process with this pid running? EPERM means it is, and belongs to
7
+ * someone else; only ESRCH says it is gone. */
8
+ export declare function pidAlive(pid: number): boolean;
9
+ /** Remove the merge directories whose owner is gone (and legacy ones a week
10
+ * old). Returns the names removed. Every failure is skipped — a sweep is
11
+ * housekeeping, never a reason for a session not to start. */
12
+ export declare function sweepStaleMergeDirs(dir?: string, now?: number, alive?: (pid: number) => boolean): string[];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 0.40.7 — the merge directories a killed kiso left behind.
3
+ *
4
+ * A project with its own `.kiso/mcp.json` or skills gets a merged copy in
5
+ * the temp directory (`kiso-mcp-*`, `kiso-skills-*`, trust-ui.ts). A clean
6
+ * exit removes it; `kill -9` does not, and macOS's temp cleaner removes old
7
+ * FILES but leaves directories — so every killed session left one behind
8
+ * for good (the owner's machine once held 449,956 of the test suite's).
9
+ *
10
+ * The directory's name now carries its owner's pid
11
+ * (`kiso-mcp-<pid>-XXXXXX`). At startup kiso removes the ones whose owner
12
+ * is gone; a live session's directory is never touched. Names from before
13
+ * this version carry no pid: those are removed only when untouched for a
14
+ * week, which no live session's merge can be (it is read at every reload).
15
+ * Only directories this user owns are considered; nothing else in the
16
+ * temp directory is read.
17
+ */
18
+ import { lstatSync, readdirSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ export const LEGACY_MAX_AGE_MS = 7 * 24 * 3600_000;
22
+ /** One startup never spends long here, whatever the temp directory holds. */
23
+ export const SWEEP_LIMIT = 200;
24
+ const OWNED = /^kiso-(?:mcp|skills)-(\d+)-[A-Za-z0-9]{6}$/;
25
+ const LEGACY = /^kiso-(?:mcp|skills)-[A-Za-z0-9]{6}$/;
26
+ /** The mkdtemp prefix for a merge directory: this process's pid in the name. */
27
+ export function mergeDirPrefix(kind) {
28
+ return join(tmpdir(), `kiso-${kind}-${process.pid}-`);
29
+ }
30
+ /** Is a process with this pid running? EPERM means it is, and belongs to
31
+ * someone else; only ESRCH says it is gone. */
32
+ export function pidAlive(pid) {
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ }
37
+ catch (err) {
38
+ return err.code === "EPERM";
39
+ }
40
+ }
41
+ /** Remove the merge directories whose owner is gone (and legacy ones a week
42
+ * old). Returns the names removed. Every failure is skipped — a sweep is
43
+ * housekeeping, never a reason for a session not to start. */
44
+ export function sweepStaleMergeDirs(dir = tmpdir(), now = Date.now(), alive = pidAlive) {
45
+ let names;
46
+ try {
47
+ names = readdirSync(dir);
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
53
+ const removed = [];
54
+ for (const name of names) {
55
+ if (removed.length >= SWEEP_LIMIT)
56
+ break;
57
+ const owned = OWNED.exec(name);
58
+ if (owned === null && !LEGACY.test(name))
59
+ continue;
60
+ const full = join(dir, name);
61
+ try {
62
+ const st = lstatSync(full);
63
+ if (!st.isDirectory() || (uid !== undefined && st.uid !== uid))
64
+ continue;
65
+ const stale = owned !== null ? Number(owned[1]) !== process.pid && !alive(Number(owned[1])) : now - st.mtimeMs > LEGACY_MAX_AGE_MS;
66
+ if (!stale)
67
+ continue;
68
+ rmSync(full, { recursive: true, force: true });
69
+ removed.push(name);
70
+ }
71
+ catch {
72
+ // gone already, or not ours to remove
73
+ }
74
+ }
75
+ return removed;
76
+ }
package/dist/trust-ui.js CHANGED
@@ -6,13 +6,14 @@
6
6
  * decisions. All bodies moved verbatim from index.ts.
7
7
  */
8
8
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
9
- import { homedir, tmpdir } from "node:os";
9
+ import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
12
  import { askDeclineAll, askView, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, unansweredAskView } from "@vincemakes/kiso-tui";
13
13
  import { projectArtifacts, recordTrust, trustFor } from "@vincemakes/kiso-runtime";
14
14
  import { bodyLog, currentAgentExtensions, dock, extensionsDir, kisoHome, mergedTempPaths, neverInherited } from "./state.js";
15
15
  import { guardSavedAllow } from "./protected-writes.js";
16
+ import { mergeDirPrefix } from "./temp-sweep.js";
16
17
  import { loadUserConfig, resolveProjectTrustPolicy } from "./config.js";
17
18
  import { getMode } from "./mode.js";
18
19
  /**
@@ -415,7 +416,7 @@ function applyMcpMerge(root) {
415
416
  // the paths the previous set owned deleted the file the new set had just
416
417
  // been merged into. One directory per merge, and the directory is what is
417
418
  // recorded, so the cleanup removes exactly what that merge created.
418
- const dir = mkdtempSync(join(tmpdir(), "kiso-mcp-"));
419
+ const dir = mkdtempSync(mergeDirPrefix("mcp")); // 0.40.7: the owner's pid in the name (temp-sweep.ts)
419
420
  const temp = join(dir, "mcp.json");
420
421
  writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
421
422
  process.env.KISO_MCP_CONFIG = temp;
@@ -442,7 +443,7 @@ function applySkillsMerge(root) {
442
443
  originalUserSkillsDir ??= process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
443
444
  const userDir = originalUserSkillsDir;
444
445
  const projectDir = join(root, "skills");
445
- const merged = mkdtempSync(join(tmpdir(), "kiso-skills-"));
446
+ const merged = mkdtempSync(mergeDirPrefix("skills"));
446
447
  mergedTempPaths.push(merged);
447
448
  for (const dir of readdirSyncSafe(projectDir)) {
448
449
  symlinkSync(join(projectDir, dir), join(merged, dir)); // project wins on collision
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.40.5",
3
+ "version": "0.40.7",
4
4
  "description": "kiso-code \u2014 the coding agent on kiso, the durable runtime for AI agents: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,20 +18,20 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-ask-ext": "0.40.5",
22
- "@vincemakes/kiso-core": "0.40.5",
23
- "@vincemakes/kiso-evals": "0.40.5",
24
- "@vincemakes/kiso-mcp-ext": "0.40.5",
25
- "@vincemakes/kiso-provider-anthropic": "0.40.5",
26
- "@vincemakes/kiso-provider-openai": "0.40.5",
27
- "@vincemakes/kiso-provider-openai-responses": "0.40.5",
28
- "@vincemakes/kiso-runtime": "0.40.5",
29
- "@vincemakes/kiso-skills-ext": "0.40.5",
30
- "@vincemakes/kiso-subagent-ext": "0.40.5",
31
- "@vincemakes/kiso-task-ext": "0.40.5",
32
- "@vincemakes/kiso-tools-node": "0.40.5",
33
- "@vincemakes/kiso-tui": "0.40.5",
34
- "@vincemakes/kiso-tui-cells": "0.40.5"
21
+ "@vincemakes/kiso-ask-ext": "0.40.7",
22
+ "@vincemakes/kiso-core": "0.40.7",
23
+ "@vincemakes/kiso-evals": "0.40.7",
24
+ "@vincemakes/kiso-mcp-ext": "0.40.7",
25
+ "@vincemakes/kiso-provider-anthropic": "0.40.7",
26
+ "@vincemakes/kiso-provider-openai": "0.40.7",
27
+ "@vincemakes/kiso-provider-openai-responses": "0.40.7",
28
+ "@vincemakes/kiso-runtime": "0.40.7",
29
+ "@vincemakes/kiso-skills-ext": "0.40.7",
30
+ "@vincemakes/kiso-subagent-ext": "0.40.7",
31
+ "@vincemakes/kiso-task-ext": "0.40.7",
32
+ "@vincemakes/kiso-tools-node": "0.40.7",
33
+ "@vincemakes/kiso-tui": "0.40.7",
34
+ "@vincemakes/kiso-tui-cells": "0.40.7"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^26.1.2",