@vincemakes/kiso-code 0.40.6 → 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;
@@ -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/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
@@ -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
  }
package/dist/dispatch.js CHANGED
@@ -101,13 +101,31 @@ function effortAxis(p) {
101
101
  : {}),
102
102
  };
103
103
  }
104
- /** What signs a profile in, for the listing: the env var's name, `oauth`
105
- * 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
+ }
106
120
  function signInNote(p) {
107
121
  try {
108
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
109
125
  if (auth.type === "oauth")
110
- 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";
111
129
  }
112
130
  catch {
113
131
  // unavailable — the availability mark says so; the env name still names what would sign it in
@@ -564,6 +582,8 @@ export function dispatch(line, ctx) {
564
582
  ctx.input.panelAsk(modePickView({
565
583
  header: `mode — current: ${current}`,
566
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) } : {}),
567
587
  },
568
588
  // DC-12 (design §4): a panel WAITING ON A HUMAN says ❯.
569
589
  ctx.isRunning() ? "❯ run paused" : `▸ ${current}`), (v) => resolve(v.action === "picked" ? v.result : null));
@@ -653,6 +673,9 @@ export function dispatch(line, ctx) {
653
673
  // "openai/…" hint failed with "no such model profile"
654
674
  // on exactly the fresh-install path that shows it.
655
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) } : {}),
656
679
  // the zero-profile copy is TODAY'S, verbatim: the
657
680
  // user who sees it is exactly the user who needs
658
681
  // the path spelled out
@@ -788,6 +811,10 @@ export function dispatch(line, ctx) {
788
811
  // display — a direct provider/model has none.
789
812
  profileName: direct === null ? profName : null,
790
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;
791
818
  ctx.session.setModelBinding(binding);
792
819
  setLastBinding(binding);
793
820
  setAgentModel(profile.model, profile.baseUrl);
@@ -814,6 +841,13 @@ export function dispatch(line, ctx) {
814
841
  // model beside the previous model's figure.
815
842
  ctx.modelSwitched();
816
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}`);
817
851
  }
818
852
  }
819
853
  }
package/dist/index.js CHANGED
@@ -54,6 +54,7 @@ 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
56
  import { preferences, usePreferences } from "./preferences.js";
57
+ import { sweepStaleMergeDirs } from "./temp-sweep.js";
57
58
  import { settingsLayers } from "./state.js";
58
59
  import { providerHost } from "./provider-label.js";
59
60
  import { adapterOptionsFor } from "./auth/adapter-options.js";
@@ -68,6 +69,8 @@ import { armByteTrace } from "./byte-trace.js";
68
69
  import { tmpdir, homedir } from "node:os";
69
70
  import { clipboardImage } from "./clipboard.js";
70
71
  import { cardsFromListings } from "./session-cards.js";
72
+ /** 0.40.7: the stale-merge-directory sweep runs once per process. */
73
+ let tempSwept = false;
71
74
  // The moved exports stay reachable from this entry — the test imports
72
75
  // (project-trust, coding-agent) never change (B4: zero assertion changes).
73
76
  export { applyProjectMerges } from "./trust-ui.js";
@@ -782,11 +785,17 @@ async function makeAgent(sessionId, input, modelFlag) {
782
785
  // channel and not an exported KISO_SESSIONS_DIR: a variable in
783
786
  // process.env would reach every shell child, and a kiso started from a
784
787
  // shell tool would then write into this project's folder.
785
- 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() });
786
789
  setConfigModels(merged.models ?? {});
787
790
  // CW-1 batch 2: the windows endpoints stated by refusing — read before the
788
791
  // first window is asked for (the unknown-window notice below).
789
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
+ }
790
799
  const resolved = resolveModel(modelFlag, merged);
791
800
  // AFTER the model resolves: the window a PROFILE states is about that
792
801
  // profile's model, so it cannot be read before we know which profile is
@@ -1292,7 +1301,7 @@ async function reloadAgent(old, id, input, announce = true) {
1292
1301
  rmSync(p, { recursive: true, force: true });
1293
1302
  }
1294
1303
  catch {
1295
- // 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
1296
1305
  }
1297
1306
  }
1298
1307
  mergedTempPaths.push(...oldTemps);
@@ -1321,7 +1330,7 @@ async function reloadAgent(old, id, input, announce = true) {
1321
1330
  rmSync(p, { recursive: true, force: true });
1322
1331
  }
1323
1332
  catch {
1324
- // 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
1325
1334
  }
1326
1335
  }
1327
1336
  if (announce)
@@ -2047,7 +2056,7 @@ async function main() {
2047
2056
  if (cred.type === "api-key")
2048
2057
  lines.push(` ${id.padEnd(10)} api-key ${maskSecret(cred.key)} saved ${new Date(cred.savedAt).toISOString().slice(0, 10)}`);
2049
2058
  else
2050
- 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)" : ""}`);
2051
2060
  }
2052
2061
  const envs = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].filter((k) => process.env[k] !== undefined);
2053
2062
  lines.push(`env vars set: ${envs.length ? envs.join(", ") : "none"} (a stored credential owns its provider; env applies only when nothing is stored)`);
@@ -2198,7 +2207,7 @@ async function main() {
2198
2207
  rmSync(p, { recursive: true, force: true });
2199
2208
  }
2200
2209
  catch {
2201
- // 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
2202
2211
  }
2203
2212
  }
2204
2213
  }
@@ -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,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.6",
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.6",
22
- "@vincemakes/kiso-core": "0.40.6",
23
- "@vincemakes/kiso-evals": "0.40.6",
24
- "@vincemakes/kiso-mcp-ext": "0.40.6",
25
- "@vincemakes/kiso-provider-anthropic": "0.40.6",
26
- "@vincemakes/kiso-provider-openai": "0.40.6",
27
- "@vincemakes/kiso-provider-openai-responses": "0.40.6",
28
- "@vincemakes/kiso-runtime": "0.40.6",
29
- "@vincemakes/kiso-skills-ext": "0.40.6",
30
- "@vincemakes/kiso-subagent-ext": "0.40.6",
31
- "@vincemakes/kiso-task-ext": "0.40.6",
32
- "@vincemakes/kiso-tools-node": "0.40.6",
33
- "@vincemakes/kiso-tui": "0.40.6",
34
- "@vincemakes/kiso-tui-cells": "0.40.6"
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",