@vincemakes/kiso-code 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -52,6 +52,13 @@ export interface KisoConfig {
52
52
  readonly contextWindow?: number;
53
53
  readonly autoCompact?: AutoCompactConfig;
54
54
  readonly projectTrust?: "ask" | "never";
55
+ /** DC-3 §3 rung 1, persisted. The terminal's light/dark, for terminals
56
+ * that answer neither `CSI ? 996 n` nor OSC 11. USER-LEVEL ONLY: a
57
+ * terminal is a property of the human sitting at one, not of the
58
+ * repository they happen to have open, so the same setting in a
59
+ * project file is a LOUD error rather than a silent win. `KISO_THEME`
60
+ * still outranks it — the environment is the more local answer. */
61
+ readonly theme?: "dark" | "light";
55
62
  }
56
63
  /** The resolved, merged config — project wins over user, both validated. */
57
64
  export interface ResolvedConfig {
package/dist/config.js CHANGED
@@ -51,6 +51,18 @@ export function parseConfig(text, source) {
51
51
  const fail = (key, why) => {
52
52
  throw new ConfigError(`config ${source}: ${key} — ${why}`);
53
53
  };
54
+ if (obj.theme !== undefined) {
55
+ // LOUD on both counts: an invalid value, and a valid value in the
56
+ // wrong file. A theme silently ignored is the worst outcome here —
57
+ // the human set it precisely because their terminal answers
58
+ // nothing, so a quiet failure looks exactly like the defect it was
59
+ // meant to fix.
60
+ if (obj.theme !== "dark" && obj.theme !== "light")
61
+ fail("theme", 'expected "dark" or "light"');
62
+ if (source.startsWith("<cwd>"))
63
+ fail("theme", "belongs in the USER config — a terminal is a property of the person at it, not of the project");
64
+ out.theme = obj.theme;
65
+ }
54
66
  if (obj.model !== undefined) {
55
67
  if (typeof obj.model !== "string" || obj.model === "")
56
68
  fail("model", "expected a profile name or provider/model string");
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
40
40
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
41
41
  import { autoCompactFromEnv, chat, contextWindowTokens, estimateCtxRatio } from "./chat.js";
42
42
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
43
+ import { checkForUpdate } from "./update-check.js";
43
44
  import { resume } from "./resume.js";
44
45
  import { resumeTail } from "./resume-tail.js";
45
46
  import { armByteTrace } from "./byte-trace.js";
@@ -229,7 +230,23 @@ function editorInput(editor) {
229
230
  /** One input source per process: the raw-mode Editor on a TTY (entered
230
231
  * here, bound to the dock once — the trust question, chat, and resume
231
232
  * all read through it), readline elsewhere. */
233
+ /** The user config's `theme`, read the way the mode is (index.ts's
234
+ * `loadUserConfig()?.mode`): the ground is settled inside
235
+ * `makeLineInput`, which runs long before `mergedConfig` exists, so the
236
+ * merged view is not available and the USER file is the only one that
237
+ * may carry it anyway (config.ts rejects a project-level theme LOUDLY).
238
+ * A broken config is not this function's business to report — the
239
+ * loader is LOUD about that on its own path. */
240
+ function userThemeSetting() {
241
+ try {
242
+ return loadUserConfig()?.theme;
243
+ }
244
+ catch {
245
+ return undefined;
246
+ }
247
+ }
232
248
  function makeLineInput() {
249
+ const userTheme = userThemeSetting();
233
250
  if (process.stdin.isTTY) {
234
251
  const editor = new Editor(() => (dock.active ? dock.redraw() : editor.selfRender()));
235
252
  editor.enter();
@@ -254,22 +271,45 @@ function makeLineInput() {
254
271
  // exists FOR the terminals without OSC 11, and could only ever be
255
272
  // consulted when an OSC reply had arrived and been malformed. With
256
273
  // §3.2 recording that rung 2 is unmeasured, that was the mainline.
257
- setGround(resolveGround({ theme: process.env.KISO_THEME, colorfgbg: process.env.COLORFGBG }));
258
- // the reply, when there is one, re-walks the ladder WITH it — and
259
- // `theme` goes in first again, so an explicit answer still wins.
260
- editor.onOsc((reply) => {
261
- const next = resolveGround({ theme: process.env.KISO_THEME, osc: reply, colorfgbg: process.env.COLORFGBG });
274
+ // The two answers accumulate here; whichever arrives first is used
275
+ // at once, and the ladder decides between them when both have.
276
+ let osc;
277
+ let colorScheme;
278
+ const theme = () => process.env.KISO_THEME ?? userTheme;
279
+ const rewalk = () => {
280
+ const next = resolveGround({ theme: theme(), colorScheme, osc, colorfgbg: process.env.COLORFGBG });
281
+ // DC-3/DC-14's model: the first frame never waits for a reply.
282
+ // A reply that changes nothing repaints nothing — which is also
283
+ // why two answers that AGREE cost one repaint and not two.
262
284
  if (next === currentGround())
263
285
  return;
264
286
  setGround(next);
265
287
  body.onGroundChange();
288
+ };
289
+ setGround(resolveGround({ theme: theme(), colorfgbg: process.env.COLORFGBG }));
290
+ // the reply, when there is one, re-walks the ladder WITH it — and
291
+ // `theme` goes in first again, so an explicit answer still wins.
292
+ editor.onOsc((reply) => {
293
+ osc = reply;
294
+ rewalk();
295
+ });
296
+ // …and the terminal's OWN account of its scheme, which outranks the
297
+ // luminance kiso would infer from a background colour (§3 rung 2).
298
+ editor.onColorScheme((scheme) => {
299
+ colorScheme = scheme;
300
+ rewalk();
266
301
  });
267
302
  // The query is COLOUR machinery, so it obeys the colour gate: a piped
268
303
  // stdout and NO_COLOR both carry zero ANSI, and an OSC written into a
269
304
  // pipe would be the first byte to break that. `palette().bold` is the
270
305
  // same test the dock activates on — one gate, not a second opinion.
306
+ // Both questions ride ONE write and ONE gate: `CSI ? 996 n` asks the
307
+ // terminal to report its colour scheme, OSC 11 asks for its
308
+ // background colour. Neither is waited on (§3.2) — a terminal that
309
+ // answers neither leaves the ground `unknown`, which is a supported
310
+ // palette rather than a failure.
271
311
  if (palette().bold !== "" && process.stdout.isTTY)
272
- process.stdout.write("\x1b]11;?\x07");
312
+ process.stdout.write("\x1b[?996n\x1b]11;?\x07");
273
313
  // W6: the box's prompt goes light — "› " (the box already says
274
314
  // "input lives here"; the line-mode path keeps the brick ▌, so
275
315
  // pipe bytes do not change)
@@ -318,6 +358,35 @@ function bannerExtensionText() {
318
358
  * W5 resume list as a LIVE banner cell (W1: the tier re-derives on
319
359
  * resize; the resume list re-gates with the tier); off-TTY: the
320
360
  * historical `[N extensions: ...]` standalone line (zero change). */
361
+ /**
362
+ * The update line, when there is one, appended after the opening.
363
+ *
364
+ * It never blocks: the caller does not await it, and the banner is
365
+ * already drawn when this starts. If the check fails, times out, is
366
+ * switched off, or finds nothing newer, this does nothing at all and
367
+ * says nothing about having tried.
368
+ */
369
+ async function announceUpdate() {
370
+ try {
371
+ const line = await checkForUpdate({ kisoHome: kisoHome(), version: VERSION, isTTY: process.stdout.isTTY === true, faux: currentFaux });
372
+ if (line === null)
373
+ return;
374
+ // THE RAW CHANNEL, not `notice`. A notice renders through ErrorLine,
375
+ // which calls `escapeTerminal` — so every SGR in it is stripped and
376
+ // the line could only ever be full-strength prose. The raw channel
377
+ // is the one the banner itself uses: the styling is applied HERE, at
378
+ // composition, and the fold keeps the span intact (#16b).
379
+ //
380
+ // A turn may have started while the request was in flight. A raw
381
+ // cell is a cell like any other, so it lands at the transcript's
382
+ // end — never spliced into a frame the reader is mid-way through.
383
+ bodyLog(`${palette().dim}${line}${palette().reset}`, "words");
384
+ }
385
+ catch {
386
+ // belt and braces: checkForUpdate does not throw, and if it ever
387
+ // did, a version check is not a reason to disturb a session.
388
+ }
389
+ }
321
390
  function extensionsBanner(resume = []) {
322
391
  const text = bannerExtensionText();
323
392
  if (!process.stdout.isTTY) {
@@ -654,6 +723,12 @@ async function chatLoop(agent, firstId, input, autoCompact) {
654
723
  // throws on profile drift — so one drifted session anywhere in the
655
724
  // history stopped kiso from starting at all.
656
725
  extensionsBanner();
726
+ // The one line the opening may gain (§7.10's ruling): "there is
727
+ // a newer kiso". FIRED AND FORGOTTEN — the banner above is
728
+ // already on screen and this never delays it. When the answer
729
+ // arrives it rides `body.notice`, the commit path notices
730
+ // already use, rather than a second channel into the frame.
731
+ void announceUpdate();
657
732
  }
658
733
  else {
659
734
  bodyLog(`session ${id} (switched — previous: ${prev}, /resume ${prev} returns)\n`);
@@ -0,0 +1,65 @@
1
+ /**
2
+ * "There is a newer kiso."
3
+ *
4
+ * The owner's ruling on the opening (2026-09-02): no logo, the name is
5
+ * the mark (§7.10 stands), and the banner may gain AT MOST one line
6
+ * saying a new version exists. This is that one line's machinery, and
7
+ * almost all of it is about the things it must not do.
8
+ *
9
+ * IT IS kiso's ONLY OUTBOUND REQUEST. Everything else kiso sends goes to
10
+ * the model endpoint the human configured. This one goes to the public
11
+ * npm registry, carries no identity, no usage and no session data — a
12
+ * GET of one dist-tags document — and can be switched off entirely with
13
+ * `KISO_NO_UPDATE_CHECK=1`. That is a real change in what the product
14
+ * is, which is why it is stated here rather than buried.
15
+ *
16
+ * WHAT IT MUST NEVER DO:
17
+ *
18
+ * - block the first frame. The check is fired and forgotten; the
19
+ * opening is drawn from local facts and never waits.
20
+ * - say anything when it fails. No network, a proxy, a 500, a
21
+ * malformed body, a timeout — all of them are silence. A version
22
+ * check that interrupts you to report that it could not check is
23
+ * worse than no version check.
24
+ * - run where it cannot be wanted: a faux session (the test and demo
25
+ * path), a non-TTY (a pipe has no banner to append to), or an
26
+ * explicit opt-out.
27
+ * - ask twice in a day, or nag about a version it has already
28
+ * mentioned. Both are remembered in the cache file.
29
+ */
30
+ export interface UpdateCache {
31
+ /** epoch ms of the last COMPLETED check, successful or not */
32
+ readonly checkedAt: number;
33
+ /** the latest version the registry reported, if the check succeeded */
34
+ readonly latest?: string;
35
+ /** the version already mentioned to this human — never mentioned twice */
36
+ readonly told?: string;
37
+ }
38
+ /**
39
+ * Semver-ish "is b newer than a", for the shapes this line ever sees:
40
+ * three numeric parts, optionally a prerelease we treat as older. A
41
+ * version we cannot parse is never "newer" — the failure direction is
42
+ * silence, here as everywhere else in this file.
43
+ */
44
+ export declare function isNewer(current: string, latest: string): boolean;
45
+ /** The one line, when there is one. Words only — a human can read it,
46
+ * paste it, and nothing about it needs colour to be understood (§1.2). */
47
+ export declare const updateLine: (latest: string) => string;
48
+ export interface UpdateCheckDeps {
49
+ readonly kisoHome: string;
50
+ readonly version: string;
51
+ readonly isTTY: boolean;
52
+ readonly faux: boolean;
53
+ readonly now?: number;
54
+ }
55
+ /**
56
+ * Should this launch even ask? Pure, so the reasons are testable without
57
+ * a network, a clock or a terminal.
58
+ */
59
+ export declare function shouldCheck(deps: UpdateCheckDeps): boolean;
60
+ /**
61
+ * The check itself: fire, forget, and resolve to the line to show or
62
+ * null. NEVER throws, never rejects, never blocks a caller who does not
63
+ * await it.
64
+ */
65
+ export declare function checkForUpdate(deps: UpdateCheckDeps): Promise<string | null>;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * "There is a newer kiso."
3
+ *
4
+ * The owner's ruling on the opening (2026-09-02): no logo, the name is
5
+ * the mark (§7.10 stands), and the banner may gain AT MOST one line
6
+ * saying a new version exists. This is that one line's machinery, and
7
+ * almost all of it is about the things it must not do.
8
+ *
9
+ * IT IS kiso's ONLY OUTBOUND REQUEST. Everything else kiso sends goes to
10
+ * the model endpoint the human configured. This one goes to the public
11
+ * npm registry, carries no identity, no usage and no session data — a
12
+ * GET of one dist-tags document — and can be switched off entirely with
13
+ * `KISO_NO_UPDATE_CHECK=1`. That is a real change in what the product
14
+ * is, which is why it is stated here rather than buried.
15
+ *
16
+ * WHAT IT MUST NEVER DO:
17
+ *
18
+ * - block the first frame. The check is fired and forgotten; the
19
+ * opening is drawn from local facts and never waits.
20
+ * - say anything when it fails. No network, a proxy, a 500, a
21
+ * malformed body, a timeout — all of them are silence. A version
22
+ * check that interrupts you to report that it could not check is
23
+ * worse than no version check.
24
+ * - run where it cannot be wanted: a faux session (the test and demo
25
+ * path), a non-TTY (a pipe has no banner to append to), or an
26
+ * explicit opt-out.
27
+ * - ask twice in a day, or nag about a version it has already
28
+ * mentioned. Both are remembered in the cache file.
29
+ */
30
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+ /** The dist-tags document — the smallest thing the registry will tell us
33
+ * that answers the question. The full packument is megabytes. */
34
+ const ENDPOINT = "https://registry.npmjs.org/-/package/@vincemakes/kiso-code/dist-tags";
35
+ const TIMEOUT_MS = 1_500;
36
+ const EVERY_MS = 24 * 60 * 60 * 1_000;
37
+ /** The registry, redirectable for tests ONLY. A stub server's URL goes
38
+ * here so the suite can drive every branch — a slow answer, no answer,
39
+ * a malformed body — without reaching the network. Never documented for
40
+ * users: it is not a setting, it is a seam. */
41
+ const endpoint = () => process.env.KISO_UPDATE_ENDPOINT ?? ENDPOINT;
42
+ const cachePath = (kisoHome) => join(kisoHome, "update-check.json");
43
+ function readCache(kisoHome) {
44
+ try {
45
+ const raw = JSON.parse(readFileSync(cachePath(kisoHome), "utf8"));
46
+ if (raw === null || typeof raw !== "object")
47
+ return null;
48
+ const c = raw;
49
+ if (typeof c.checkedAt !== "number")
50
+ return null;
51
+ return {
52
+ checkedAt: c.checkedAt,
53
+ ...(typeof c.latest === "string" ? { latest: c.latest } : {}),
54
+ ...(typeof c.told === "string" ? { told: c.told } : {}),
55
+ };
56
+ }
57
+ catch {
58
+ // a missing, unreadable or corrupt cache is simply no cache
59
+ return null;
60
+ }
61
+ }
62
+ function writeCache(kisoHome, c) {
63
+ try {
64
+ mkdirSync(dirname(cachePath(kisoHome)), { recursive: true });
65
+ writeFileSync(cachePath(kisoHome), `${JSON.stringify(c)}\n`, "utf8");
66
+ }
67
+ catch {
68
+ // an unwritable home means we check again next time — the only
69
+ // cost is a request, and there is nothing here worth a message.
70
+ }
71
+ }
72
+ /**
73
+ * Semver-ish "is b newer than a", for the shapes this line ever sees:
74
+ * three numeric parts, optionally a prerelease we treat as older. A
75
+ * version we cannot parse is never "newer" — the failure direction is
76
+ * silence, here as everywhere else in this file.
77
+ */
78
+ export function isNewer(current, latest) {
79
+ const parse = (v) => {
80
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
81
+ return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
82
+ };
83
+ const a = parse(current);
84
+ const b = parse(latest);
85
+ if (a === null || b === null)
86
+ return false;
87
+ // a prerelease of the same numbers is not newer
88
+ if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2])
89
+ return false;
90
+ for (let i = 0; i < 3; i += 1) {
91
+ if (b[i] > a[i])
92
+ return true;
93
+ if (b[i] < a[i])
94
+ return false;
95
+ }
96
+ return false;
97
+ }
98
+ /** The one line, when there is one. Words only — a human can read it,
99
+ * paste it, and nothing about it needs colour to be understood (§1.2). */
100
+ export const updateLine = (latest) => `${latest} is out · npm i -g @vincemakes/kiso-code@latest`;
101
+ /**
102
+ * Should this launch even ask? Pure, so the reasons are testable without
103
+ * a network, a clock or a terminal.
104
+ */
105
+ export function shouldCheck(deps) {
106
+ return reasonNotToCheck(deps) === null;
107
+ }
108
+ /**
109
+ * WHY a launch does not ask — and the distinction matters, because only
110
+ * ONE of these reasons permits the line to be shown from cache.
111
+ *
112
+ * "recent" means the request is redundant; the human is still someone
113
+ * who wants to be told. The other three mean the LINE is unwanted: an
114
+ * opt-out is an answer about the feature, a faux session is a test or a
115
+ * demo, and a pipe has no banner — worse, an inactive Body writes a
116
+ * notice straight to stdout, so showing it there would put a row of
117
+ * kiso's own prose into piped bytes and break pipe identity.
118
+ */
119
+ function reasonNotToCheck({ kisoHome, isTTY, faux, now = Date.now() }) {
120
+ if (process.env.KISO_NO_UPDATE_CHECK === "1")
121
+ return "opt-out";
122
+ // A faux session is the test and demo path, and the rule it exists for
123
+ // is "never reaches the network". The redirect below is a TEST SEAM
124
+ // pointing at a local stub — where it is set there is no network to
125
+ // reach, and refusing anyway would make the one behaviour worth
126
+ // showing on a screen unobservable. In production nothing sets it, so
127
+ // a faux session still never asks.
128
+ if (faux && process.env.KISO_UPDATE_ENDPOINT === undefined)
129
+ return "faux";
130
+ if (!isTTY)
131
+ return "not-a-tty"; // and never writes a line into a pipe
132
+ const cached = readCache(kisoHome);
133
+ if (cached !== null && now - cached.checkedAt < EVERY_MS)
134
+ return "recent";
135
+ return null;
136
+ }
137
+ /**
138
+ * The check itself: fire, forget, and resolve to the line to show or
139
+ * null. NEVER throws, never rejects, never blocks a caller who does not
140
+ * await it.
141
+ */
142
+ export async function checkForUpdate(deps) {
143
+ const { kisoHome, version, now = Date.now() } = deps;
144
+ const why = reasonNotToCheck(deps);
145
+ if (why !== null) {
146
+ // ONLY a recent check may still speak. The other three reasons are
147
+ // about the LINE, not about the request, so the cache is no way
148
+ // around them — an opt-out that still got announced, or a piped
149
+ // run that gained a row of kiso's prose, would be the feature
150
+ // ignoring the answer it was given.
151
+ if (why !== "recent")
152
+ return null;
153
+ const cached = readCache(kisoHome);
154
+ if (cached?.latest !== undefined && cached.told !== cached.latest && isNewer(version, cached.latest)) {
155
+ writeCache(kisoHome, { ...cached, told: cached.latest });
156
+ return updateLine(cached.latest);
157
+ }
158
+ return null;
159
+ }
160
+ let latest;
161
+ try {
162
+ const ctl = new AbortController();
163
+ const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
164
+ try {
165
+ const res = await fetch(endpoint(), { signal: ctl.signal, headers: { accept: "application/json" } });
166
+ if (res.ok) {
167
+ const body = await res.json();
168
+ const tag = body?.latest;
169
+ if (typeof tag === "string" && tag !== "")
170
+ latest = tag;
171
+ }
172
+ }
173
+ finally {
174
+ clearTimeout(timer);
175
+ }
176
+ }
177
+ catch {
178
+ // every failure is silence — see the header
179
+ }
180
+ const prev = readCache(kisoHome);
181
+ const show = latest !== undefined && isNewer(version, latest) && prev?.told !== latest;
182
+ // `told` survives a check that learned nothing: forgetting it would
183
+ // re-announce a version this human has already been told about.
184
+ const told = show ? latest : prev?.told;
185
+ writeCache(kisoHome, {
186
+ checkedAt: now,
187
+ ...(latest === undefined ? {} : { latest }),
188
+ ...(told === undefined ? {} : { told }),
189
+ });
190
+ return show && latest !== undefined ? updateLine(latest) : null;
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.21.1",
3
+ "version": "0.23.0",
4
4
  "description": "kiso CLI — the durable coding agent that survives kill -9: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,19 +18,19 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-ask-ext": "0.21.1",
22
- "@vincemakes/kiso-core": "0.21.1",
23
- "@vincemakes/kiso-evals": "0.21.1",
24
- "@vincemakes/kiso-mcp-ext": "0.21.1",
25
- "@vincemakes/kiso-provider-anthropic": "0.21.1",
26
- "@vincemakes/kiso-provider-openai": "0.21.1",
27
- "@vincemakes/kiso-runtime": "0.21.1",
28
- "@vincemakes/kiso-skills-ext": "0.21.1",
29
- "@vincemakes/kiso-subagent-ext": "0.21.1",
30
- "@vincemakes/kiso-task-ext": "0.21.1",
31
- "@vincemakes/kiso-tools-node": "0.21.1",
32
- "@vincemakes/kiso-tui": "0.21.1",
33
- "@vincemakes/kiso-tui-cells": "0.21.1"
21
+ "@vincemakes/kiso-ask-ext": "0.23.0",
22
+ "@vincemakes/kiso-core": "0.23.0",
23
+ "@vincemakes/kiso-evals": "0.23.0",
24
+ "@vincemakes/kiso-mcp-ext": "0.23.0",
25
+ "@vincemakes/kiso-provider-anthropic": "0.23.0",
26
+ "@vincemakes/kiso-provider-openai": "0.23.0",
27
+ "@vincemakes/kiso-runtime": "0.23.0",
28
+ "@vincemakes/kiso-skills-ext": "0.23.0",
29
+ "@vincemakes/kiso-subagent-ext": "0.23.0",
30
+ "@vincemakes/kiso-task-ext": "0.23.0",
31
+ "@vincemakes/kiso-tools-node": "0.23.0",
32
+ "@vincemakes/kiso-tui": "0.23.0",
33
+ "@vincemakes/kiso-tui-cells": "0.23.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^26.1.2",