@vincemakes/kiso-code 0.32.0 → 0.32.2

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.
@@ -45,22 +45,52 @@ const ORIGIN_TO_PROVIDER = {
45
45
  * openai-CHAT profiles, and the subscription backend serves only the
46
46
  * Responses dialect. */
47
47
  const CHATGPT_ORIGIN = "https://chatgpt.com";
48
+ /** R1 — the vendors' OWN origins. A profile whose baseUrl points anywhere
49
+ * else authenticates with `apiKeyEnv` and nothing else, however familiar
50
+ * the dialect it speaks. */
51
+ const ANTHROPIC_ORIGIN = "https://api.anthropic.com";
52
+ const OPENAI_ORIGIN = "https://api.openai.com";
48
53
  /** The provider identity a profile's credential is stored under, or null
49
54
  * for an origin nobody recognizes (custom: env var only). */
50
55
  export function providerIdOf(kind, baseUrl) {
51
- if (kind === "anthropic")
52
- return "anthropic";
53
- // OR-1: exactly two identities serve the Responses dialect — the
54
- // ChatGPT subscription backend, and OpenAI. There is no `custom`
55
- // bucket: a Responses endpoint that is neither is still OpenAI's API
56
- // shape behind a proxy, and its credential is OpenAI's.
57
- if (kind === "openai-responses")
58
- return originOf(baseUrl) === CHATGPT_ORIGIN ? "chatgpt" : "openai";
56
+ // R1 — A STORED CREDENTIAL NEVER LEAVES THE VENDOR'S OWN ORIGIN.
57
+ //
58
+ // This returned `anthropic` for every anthropic-kind profile and
59
+ // `openai` for every non-ChatGPT Responses profile, whatever the
60
+ // baseUrl said. `authForProfile` takes the stored credential BEFORE
61
+ // the profile's `apiKeyEnv`, so a profile pointing at a proxy — with
62
+ // an explicit key named for that proxy — sent THE VENDOR'S STORED KEY
63
+ // to the proxy and never read the env var at all. Reproduced against a
64
+ // local capture in the 2026-09-10 external review.
65
+ //
66
+ // OR-1's reasoning is superseded for this question, and the sentence it
67
+ // turned on is where it went wrong: "a Responses endpoint that is
68
+ // neither is still OpenAI's API shape behind a proxy, and its
69
+ // credential is OpenAI's". The DIALECT is OpenAI's; the CREDENTIAL is
70
+ // not. Who speaks the protocol and who should be paid to answer are
71
+ // different questions, and only the second one decides where a secret
72
+ // may go.
73
+ //
74
+ // The compat kind was already right — an unrecognized origin resolves
75
+ // to null and the env var pays — so this makes the other two agree with
76
+ // the one that had the rule.
77
+ const origin = originOf(baseUrl);
78
+ if (kind === "anthropic") {
79
+ if (baseUrl === undefined)
80
+ return "anthropic"; // the vendor's own default endpoint
81
+ return origin === ANTHROPIC_ORIGIN ? "anthropic" : null;
82
+ }
83
+ if (kind === "openai-responses") {
84
+ if (baseUrl === undefined)
85
+ return "openai"; // the vendor's own default endpoint
86
+ if (origin === CHATGPT_ORIGIN)
87
+ return "chatgpt";
88
+ return origin === OPENAI_ORIGIN ? "openai" : null;
89
+ }
59
90
  if (kind !== "openai-compat")
60
91
  return null;
61
92
  if (baseUrl === undefined)
62
93
  return "openai";
63
- const origin = originOf(baseUrl);
64
94
  return origin === null ? null : (ORIGIN_TO_PROVIDER[origin] ?? null);
65
95
  }
66
96
  function originOf(baseUrl) {
@@ -29,12 +29,39 @@ export async function ensureFresh(providerId, flow, path, now = Date.now(), toke
29
29
  if (refreshed === undefined)
30
30
  throw new AuthError(`signed out of ${providerId} (the token could not be refreshed: ${error?.message ?? "unknown"}) — run \`kiso login ${providerId}\``);
31
31
  const written = refreshed;
32
+ // R2a — THE WRITE-BACK MAY NOT RESURRECT WHAT THE HUMAN REMOVED.
33
+ //
34
+ // `flow.refresh` is awaited outside the store lock, so anything can
35
+ // happen to the file while it is on the wire. The old condition kept
36
+ // only a LATER-EXPIRING entry, which asks the wrong question: an entry
37
+ // deleted by `kiso logout` is not later-expiring, it is ABSENT, so the
38
+ // branch fell through and wrote the old credential back — a sign-out
39
+ // the human performed, undone by a network round trip they never saw.
40
+ // A new login of another type was overwritten the same way: an api-key
41
+ // entry is not an oauth entry with a later expiry either.
42
+ //
43
+ // The question that is actually being asked is "is this still the
44
+ // generation I began from?", so that is what is compared: the refresh
45
+ // token and the expiry captured BEFORE the await. Absent, a different
46
+ // type, or a different generation — including the concurrent refresh
47
+ // the old condition was written for — all leave the file alone.
48
+ const began = current;
32
49
  modifyAuthFile((file) => {
33
50
  const latest = file.credentials[providerId];
34
- // if a concurrent refresh already wrote a fresher token, keep it
35
- if (latest !== undefined && latest.type === "oauth" && latest.expires > written.expires)
36
- return file;
51
+ if (latest === undefined)
52
+ return file; // logged out while we were on the wire
53
+ if (latest.type !== "oauth")
54
+ return file; // replaced by a login of another type
55
+ if (latest.refresh !== began.refresh || latest.expires !== began.expires)
56
+ return file; // a different generation
37
57
  return { version: 1, credentials: { ...file.credentials, [providerId]: written } };
38
58
  }, path);
39
- return readAuthFile(path).credentials[providerId];
59
+ // What the FILE holds, not what this refresh produced: if the write was
60
+ // declined the caller must be told the truth on disk, and an absent
61
+ // entry is the not-signed-in error rather than an undefined cast to a
62
+ // Credential.
63
+ const after = readAuthFile(path).credentials[providerId];
64
+ if (after === undefined)
65
+ throw new AuthError(`not signed in to ${providerId}: run \`kiso login ${providerId}\``);
66
+ return after;
40
67
  }
@@ -41,7 +41,13 @@ function line(dir, data) {
41
41
  return;
42
42
  const buf = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
43
43
  try {
44
- appendFileSync(path, `${JSON.stringify({ ms: Date.now() - start, dir, n: buf.length, b: buf.toString("base64") })}\n`);
44
+ // R6b: 0600 at CREATION. This file is every keystroke and every
45
+ // paste, base64 — the session log plus the things that never became
46
+ // a turn, including anything typed and deleted. It is opt-in
47
+ // debugging, which makes it easy to leave behind and easy to forget
48
+ // is there; it is the most sensitive file kiso writes and it was the
49
+ // least protected. An existing file keeps its mode (R6's rule).
50
+ appendFileSync(path, `${JSON.stringify({ ms: Date.now() - start, dir, n: buf.length, b: buf.toString("base64") })}\n`, { mode: 0o600 });
45
51
  }
46
52
  catch {
47
53
  // a trace that fails must never take the session with it
@@ -65,7 +71,12 @@ export function armByteTrace() {
65
71
  path = target;
66
72
  start = Date.now();
67
73
  try {
68
- writeFileSync(path, "");
74
+ // R6b: the mode belongs HERE, where the file is created. The append
75
+ // below states it too, but `appendFileSync`'s mode only applies when
76
+ // it creates the file — and this truncating write always gets there
77
+ // first, so without this the trace was born 0644 and the append
78
+ // never had a chance to say otherwise.
79
+ writeFileSync(path, "", { mode: 0o600 });
69
80
  }
70
81
  catch {
71
82
  path = null;
package/dist/chat.js CHANGED
@@ -8,6 +8,7 @@ import { readFileSync, statSync } from "node:fs";
8
8
  import { escapeTerminal, cacheHitPct, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, verifyOfferView, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
9
9
  import { askView, deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
+ import { echoText } from "@vincemakes/kiso-tui-cells/render";
11
12
  import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
12
13
  import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
13
14
  import { dispatch, abortBangCommand } from "./dispatch.js";
@@ -638,7 +639,9 @@ submitTurn) {
638
639
  body.notice(` ${typeof ev.content === "string" ? ev.content : ""}`);
639
640
  break;
640
641
  }
641
- body.userLine(typeof ev.content === "string" ? ev.content : "");
642
+ // DC-60: a turn that carries an image is a content ARRAY — its words
643
+ // still echo, the image as a mark; an empty chip was the bug.
644
+ body.userLine(echoText(ev.content));
642
645
  break;
643
646
  case "thinking":
644
647
  body.thinkingAppend(ev.text);
package/dist/dispatch.js CHANGED
@@ -768,6 +768,17 @@ export function dispatch(line, ctx) {
768
768
  });
769
769
  return;
770
770
  }
771
+ if (trimmed === "" && dock.active) {
772
+ // DC-57 (owner ruling 2026-09-10): a bare Enter on the EMPTY composer
773
+ // does nothing. It used to fall through to the exit below — a rule
774
+ // from the readline era, where an empty line on a pipe means the
775
+ // input is over — and the docked editor inherited it, so a stray
776
+ // Enter at the idle prompt ended the session (measured on 0.32.0).
777
+ // The pipe path keeps its byte-pinned end; the dock keeps `exit`
778
+ // and ctrl+c as the ways out.
779
+ ctx.input.prompt();
780
+ return;
781
+ }
771
782
  if (trimmed === "exit" || trimmed === "") {
772
783
  // PH-1a (finding PH-F11): closing the input MID-RUN killed the very
773
784
  // surface a later approval would ask through ("readline was
@@ -33,11 +33,16 @@ import { join } from "node:path";
33
33
  export function runExternalEditor(bin, root, text, onFailure) {
34
34
  // per INVOCATION, not per run: a directory per call is how the tree
35
35
  // grew 400,000 of them once.
36
- mkdirSync(root, { recursive: true });
36
+ // R6b: the draft is the human's own words on their way to an editor.
37
+ // `mkdtemp` already gives its own directory 0700, so the file was
38
+ // shielded by WHERE IT SITS rather than by what it is — and the root
39
+ // above it was created with no mode at all. Both stated now, so the
40
+ // shield is the file's own and does not depend on a neighbour's default.
41
+ mkdirSync(root, { recursive: true, mode: 0o700 });
37
42
  const dir = mkdtempSync(join(root, "compose-"));
38
43
  const file = join(dir, "message.md"); // .md so the editor lights it up
39
44
  try {
40
- writeFileSync(file, text, "utf8");
45
+ writeFileSync(file, text, { encoding: "utf8", mode: 0o600 });
41
46
  const r = spawnSync(bin, [file], { stdio: "inherit", shell: false });
42
47
  if (r.error !== undefined || (r.status !== null && r.status !== 0)) {
43
48
  onFailure(`[ctrl+g] ${bin} exited ${r.status ?? "abnormally"} — the composer is unchanged`);
package/dist/first-run.js CHANGED
@@ -29,9 +29,14 @@ export function isFirstRun() {
29
29
  * first-run sequence is evidenced by the FILES, not an announcement. */
30
30
  export function scaffoldFirstRun() {
31
31
  const home = kisoHome();
32
- mkdirSync(home, { recursive: true });
32
+ // R6: the home is PRIVATE from its first moment. This is usually the
33
+ // first thing that creates it, so the mode set here is the one every
34
+ // later writer inherits by finding the directory already there. The
35
+ // config may name an env var and the sentinel is empty, but the
36
+ // directory itself will hold session logs and the history.
37
+ mkdirSync(home, { recursive: true, mode: 0o700 });
33
38
  const config = join(home, "config.json");
34
39
  if (!existsSync(config))
35
- writeFileSync(config, CONFIG_SCAFFOLD, "utf8");
36
- writeFileSync(join(home, SENTINEL), "", "utf8");
40
+ writeFileSync(config, CONFIG_SCAFFOLD, { encoding: "utf8", mode: 0o600 });
41
+ writeFileSync(join(home, SENTINEL), "", { encoding: "utf8", mode: 0o600 });
37
42
  }
package/dist/index.js CHANGED
@@ -45,6 +45,7 @@ import { chat, contextWindowTokens, displayCtxRatio, statusModelLabel } from "./
45
45
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
46
46
  import { oauthTokenThunk } from "./auth/token.js";
47
47
  import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
48
+ import { tmuxMouseHint } from "./tmux-hint.js";
48
49
  import { resume } from "./resume.js";
49
50
  import { resumeTail } from "./resume-tail.js";
50
51
  import { armByteTrace } from "./byte-trace.js";
@@ -1004,6 +1005,14 @@ async function chatLoop(first, firstId, input, autoCompact) {
1004
1005
  const known = knownUpdate(updateDeps());
1005
1006
  if (known !== null)
1006
1007
  paintUpdateCard(known);
1008
+ // DC-59: under tmux without `mouse on`, say why a scroll walks the
1009
+ // history — once, at start, only when it is knowable.
1010
+ const tmuxHint = tmuxMouseHint(process.env, () => {
1011
+ const r = spawnSync("tmux", ["show", "-gv", "mouse"], { encoding: "utf8", timeout: 500 });
1012
+ return r.status === 0 ? r.stdout.trim() : null;
1013
+ });
1014
+ if (tmuxHint !== null)
1015
+ body.notice(tmuxHint);
1007
1016
  void announceUpdate();
1008
1017
  }
1009
1018
  else {
@@ -1232,15 +1241,22 @@ async function main() {
1232
1241
  let seed = [];
1233
1242
  try {
1234
1243
  seed = readFileSync(historyPath, "utf8").split("\n").filter((l) => l !== "").slice(-500);
1235
- writeFileSync(historyPath, seed.length > 0 ? seed.join("\n") + "\n" : "");
1244
+ // R6: the rewrite keeps the file private. It only runs when the
1245
+ // file already existed, so this is a trim of the user's own
1246
+ // history rather than a creation — the mode is stated so the trim
1247
+ // cannot widen it.
1248
+ writeFileSync(historyPath, seed.length > 0 ? seed.join("\n") + "\n" : "", { mode: 0o600 });
1236
1249
  }
1237
1250
  catch {
1238
1251
  // no file yet, or unreadable — start empty
1239
1252
  }
1240
1253
  input.bindHistory(seed, (line) => {
1241
1254
  try {
1242
- mkdirSync(kisoHome(), { recursive: true });
1243
- appendFileSync(historyPath, line.replaceAll("\n", " ") + "\n");
1255
+ // R6: every submitted line lands here, so the history is as
1256
+ // private as the session logs — 0700 home, 0600 file, at
1257
+ // CREATION. An existing file keeps its mode (not migrated).
1258
+ mkdirSync(kisoHome(), { recursive: true, mode: 0o700 });
1259
+ appendFileSync(historyPath, line.replaceAll("\n", " ") + "\n", { mode: 0o600 });
1244
1260
  }
1245
1261
  catch {
1246
1262
  // best-effort — a full disk never breaks a submit
@@ -15,6 +15,7 @@
15
15
  * renders text. It writes nothing, decides nothing, and cannot disagree
16
16
  * with the session because it holds no state of its own.
17
17
  */
18
+ import { echoText } from "@vincemakes/kiso-tui-cells/render";
18
19
  /** How much of the tail is worth showing. Two turns is enough to
19
20
  * recognise a conversation and short enough that resuming does not
20
21
  * bury the prompt. */
@@ -34,10 +35,24 @@ export function resumeTail(events, W = 80) {
34
35
  let reply = "";
35
36
  for (const ev of events) {
36
37
  const e = ev;
37
- if (e.type === "user_input" && typeof e.content === "string") {
38
+ if (e.type === "user_input") {
39
+ // R7b: a turn carrying an IMAGE is a content ARRAY, and this kept
40
+ // only strings — so such a turn was skipped entirely. Skipping an
41
+ // ASK does not merely lose it: the reply that followed attached to
42
+ // the PREVIOUS ask, and the resumed tail showed an answer under a
43
+ // question it did not answer. Worse than showing nothing.
44
+ //
45
+ // Projected with the LIVE ECHO's own function, so the tail and the
46
+ // echo cannot drift: cli → tui-cells is the right direction. The
47
+ // RUNTIME keeps its own `contentText` (R7) because it cannot
48
+ // depend on tui-cells — two implementations, one per direction,
49
+ // agreeing on "(image)".
50
+ const text = echoText(e.content).trim();
51
+ if (text === "")
52
+ continue;
38
53
  if (ask !== null)
39
54
  turns.push({ ask, reply });
40
- ask = e.content;
55
+ ask = text;
41
56
  reply = "";
42
57
  }
43
58
  else if (e.type === "text_delta" && typeof e.text === "string" && ask !== null) {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * DC-59 (0.32.2) — the one thing kiso can say about scrolling under tmux.
3
+ *
4
+ * Under tmux without `mouse on`, the terminal turns wheel and trackpad
5
+ * scrolling into arrow keys for tmux's alternate screen and tmux passes
6
+ * them to the pane: a scroll over the idle composer walks the history
7
+ * (TMUX-F1). The editor's burst guard collapses a wheel NOTCH — three or
8
+ * more identical arrows in one read — but a smooth trackpad stream arrives
9
+ * one arrow per read and is indistinguishable from a hand. With `mouse on`
10
+ * tmux owns the wheel and kiso receives nothing (measured). So kiso names
11
+ * the cause once at start when it is knowable, and never guesses: no
12
+ * `$TMUX`, no question asked; the option unreadable, no hint.
13
+ *
14
+ * PURE on purpose: the decision takes the env and a reader; the spawn that
15
+ * asks tmux lives with the CLI's other spawns (this module's tests are
16
+ * unit tests and must stay so — the pty-manifest gate classifies by import
17
+ * closure).
18
+ */
19
+ export declare const TMUX_MOUSE_HINT = "under tmux without `mouse on`, trackpad and wheel scrolling arrive as arrow keys and walk the composer history \u2014 add `set -g mouse on` to ~/.tmux.conf";
20
+ /** The hint, or null. `show` reads tmux's global `mouse` option ("on" /
21
+ * "off"), or null when tmux cannot be asked. */
22
+ export declare function tmuxMouseHint(env: Readonly<Record<string, string | undefined>>, show: () => string | null): string | null;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * DC-59 (0.32.2) — the one thing kiso can say about scrolling under tmux.
3
+ *
4
+ * Under tmux without `mouse on`, the terminal turns wheel and trackpad
5
+ * scrolling into arrow keys for tmux's alternate screen and tmux passes
6
+ * them to the pane: a scroll over the idle composer walks the history
7
+ * (TMUX-F1). The editor's burst guard collapses a wheel NOTCH — three or
8
+ * more identical arrows in one read — but a smooth trackpad stream arrives
9
+ * one arrow per read and is indistinguishable from a hand. With `mouse on`
10
+ * tmux owns the wheel and kiso receives nothing (measured). So kiso names
11
+ * the cause once at start when it is knowable, and never guesses: no
12
+ * `$TMUX`, no question asked; the option unreadable, no hint.
13
+ *
14
+ * PURE on purpose: the decision takes the env and a reader; the spawn that
15
+ * asks tmux lives with the CLI's other spawns (this module's tests are
16
+ * unit tests and must stay so — the pty-manifest gate classifies by import
17
+ * closure).
18
+ */
19
+ export const TMUX_MOUSE_HINT = "under tmux without `mouse on`, trackpad and wheel scrolling arrive as arrow keys and walk the composer history — add `set -g mouse on` to ~/.tmux.conf";
20
+ /** The hint, or null. `show` reads tmux's global `mouse` option ("on" /
21
+ * "off"), or null when tmux cannot be asked. */
22
+ export function tmuxMouseHint(env, show) {
23
+ if (env.TMUX === undefined || env.TMUX === "")
24
+ return null;
25
+ return show() === "off" ? TMUX_MOUSE_HINT : null;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.32.0",
3
+ "version": "0.32.2",
4
4
  "description": "kiso-code — 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.32.0",
22
- "@vincemakes/kiso-core": "0.32.0",
23
- "@vincemakes/kiso-evals": "0.32.0",
24
- "@vincemakes/kiso-mcp-ext": "0.32.0",
25
- "@vincemakes/kiso-provider-anthropic": "0.32.0",
26
- "@vincemakes/kiso-provider-openai": "0.32.0",
27
- "@vincemakes/kiso-provider-openai-responses": "0.32.0",
28
- "@vincemakes/kiso-runtime": "0.32.0",
29
- "@vincemakes/kiso-skills-ext": "0.32.0",
30
- "@vincemakes/kiso-subagent-ext": "0.32.0",
31
- "@vincemakes/kiso-task-ext": "0.32.0",
32
- "@vincemakes/kiso-tools-node": "0.32.0",
33
- "@vincemakes/kiso-tui": "0.32.0",
34
- "@vincemakes/kiso-tui-cells": "0.32.0"
21
+ "@vincemakes/kiso-ask-ext": "0.32.2",
22
+ "@vincemakes/kiso-core": "0.32.2",
23
+ "@vincemakes/kiso-evals": "0.32.2",
24
+ "@vincemakes/kiso-mcp-ext": "0.32.2",
25
+ "@vincemakes/kiso-provider-anthropic": "0.32.2",
26
+ "@vincemakes/kiso-provider-openai": "0.32.2",
27
+ "@vincemakes/kiso-provider-openai-responses": "0.32.2",
28
+ "@vincemakes/kiso-runtime": "0.32.2",
29
+ "@vincemakes/kiso-skills-ext": "0.32.2",
30
+ "@vincemakes/kiso-subagent-ext": "0.32.2",
31
+ "@vincemakes/kiso-task-ext": "0.32.2",
32
+ "@vincemakes/kiso-tools-node": "0.32.2",
33
+ "@vincemakes/kiso-tui": "0.32.2",
34
+ "@vincemakes/kiso-tui-cells": "0.32.2"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^26.1.2",