@vincemakes/kiso-code 0.31.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -164,6 +164,12 @@ export const chatgptFlow = {
164
164
  const flow = createAuthorizationFlow(redirectUri);
165
165
  const server = await startCallbackServer(flow.state, cb.host, cb.port);
166
166
  interaction.notify(`open this URL to sign in:\n ${flow.url}`);
167
+ // OR-4 (owner, 2026-09-09): the URL is opened for the person when the
168
+ // caller can — the printed line stays the fallback and the paste path.
169
+ if (interaction.open !== undefined) {
170
+ interaction.open(flow.url);
171
+ interaction.notify("(opened in your browser — if nothing opened, copy the URL above)");
172
+ }
167
173
  let code = null;
168
174
  if (server !== null) {
169
175
  interaction.notify(`waiting for the browser to come back to ${redirectUri} … (or paste the redirected URL here)`);
@@ -14,6 +14,11 @@ export interface LoginInteraction {
14
14
  /** Ask the person for a line (the pasted redirect URL or code when the
15
15
  * local callback cannot be reached); resolves "" when unavailable. */
16
16
  readonly prompt: (question: string) => Promise<string>;
17
+ /** OR-4: open the authorize URL in the person's browser, when there is
18
+ * one. OPTIONAL — the CLI passes it on a TTY; tests and pipes never do,
19
+ * so nothing opens a browser under a test rig. The URL is always
20
+ * printed too: this is a convenience, never the only path. */
21
+ readonly open?: (url: string) => void;
17
22
  readonly signal?: AbortSignal;
18
23
  /** Test seam: the callback listener's host/port (defaults 127.0.0.1:1455). */
19
24
  readonly callback?: {
package/dist/chat.d.ts CHANGED
@@ -38,6 +38,15 @@ export declare function estimateCtxRatio(session: AgentSession): number;
38
38
  * their continuation envelopes, the output reserve when known) over the
39
39
  * window. Still ~ (chars/4). The auto-compact policy does NOT read this
40
40
  * (see autoCompactRatio) — moving the policy's number is A1b's. */
41
+ /** OR-8 (owner, 2026-09-09): the status row names the effort next to the
42
+ * model — `gpt-5.6-sol · xhigh` — whenever the live binding carries one;
43
+ * the provider's default shows as the bare model, as before. The effort
44
+ * is the session's own (the durable profile), never the CLI's memory. */
45
+ export declare function statusModelLabel(session: {
46
+ readonly reasoning?: {
47
+ readonly effort: string;
48
+ };
49
+ }): string;
41
50
  export declare function displayCtxRatio(session: AgentSession): number;
42
51
  /** A1a: the number the auto-compact decision reads — the pre-A1a estimate,
43
52
  * unchanged this round, named so a gate can pin that it did not move. */
package/dist/chat.js CHANGED
@@ -69,6 +69,14 @@ export function estimateCtxRatio(session) {
69
69
  * their continuation envelopes, the output reserve when known) over the
70
70
  * window. Still ~ (chars/4). The auto-compact policy does NOT read this
71
71
  * (see autoCompactRatio) — moving the policy's number is A1b's. */
72
+ /** OR-8 (owner, 2026-09-09): the status row names the effort next to the
73
+ * model — `gpt-5.6-sol · xhigh` — whenever the live binding carries one;
74
+ * the provider's default shows as the bare model, as before. The effort
75
+ * is the session's own (the durable profile), never the CLI's memory. */
76
+ export function statusModelLabel(session) {
77
+ const effort = session.reasoning?.effort;
78
+ return effort !== undefined && effort !== "default" ? `${agentModel} · ${effort}` : agentModel;
79
+ }
72
80
  export function displayCtxRatio(session) {
73
81
  return requestBudget(session.requestParts(), contextWindowTokens()).ratio;
74
82
  }
@@ -127,7 +135,24 @@ endpoint) {
127
135
  const m = Math.min(prevTotal, total) - c.cacheRead;
128
136
  missed = m > CACHE_MISS_FLOOR ? m : null;
129
137
  }
130
- return { usage: { in: c.input, out: c.output, cache: c.cacheRead, known: ev.known }, total, missed, costUsd: c.costUsd };
138
+ // OR-10 (owner, 2026-09-09): a backend whose cache figure cannot be
139
+ // observed has no cache figure. The ChatGPT backend answers
140
+ // `cached_tokens` 0 to a third-party client on every request, so its 0
141
+ // and "not reported" are one value, and a CH 0% built on it claims a
142
+ // measurement that never happened. The registry ROW says which
143
+ // endpoints these are; the status row, the recap and the usage line
144
+ // already render a null cache as nothing ("an unmeasured cache is not
145
+ // a 0% cache"), so this is one decision at the one place the endpoint
146
+ // is known. The trace ledger is written by the runtime from the
147
+ // backend's own words and keeps its 0 — the display's rule, not the
148
+ // record's.
149
+ const unobservable = model !== undefined && lookupModelMetadata(model, endpoint)?.capabilities.promptCaching === "unobservable";
150
+ return {
151
+ usage: { in: c.input, out: c.output, cache: unobservable ? null : c.cacheRead, known: ev.known },
152
+ total,
153
+ missed: unobservable ? null : missed,
154
+ costUsd: c.costUsd,
155
+ };
131
156
  }
132
157
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
133
158
  * is gone) — docked only, 200ms rotation between the request and the
@@ -791,6 +816,17 @@ submitTurn) {
791
816
  // is a sentence addressed to a human; it needs no edge.
792
817
  body.notice('answer truncated at max_tokens — say "continue" to finish');
793
818
  }
819
+ // OR-5 (the ChatGPT real leg, 2026-09-09): a run that ended in an
820
+ // ERROR terminal printed nothing but the recap — the vendor's 400
821
+ // lived in the durable log alone and the turn read as "took 1s".
822
+ // A failure that fires silently is indistinguishable from a
823
+ // crash, the one thing this product must never be: the mapped
824
+ // error is named here, before the recap, code and status and the
825
+ // provider's own words.
826
+ if (ev.outcome.kind === "error") {
827
+ const e = ev.outcome.error;
828
+ body.notice(`run failed — ${e.code}${e.status !== undefined ? ` ${e.status}` : ""}${e.retryable ? " (retryable)" : ""}: ${escapeTerminal(e.message)}`);
829
+ }
794
830
  bodyLog(renderRecap({
795
831
  seconds: Math.round((Date.now() - turnStart) / 1000),
796
832
  // R3g: the work terms are NOT passed — the fold line
@@ -1083,7 +1119,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1083
1119
  // TUI2-R1 (E): the meter rides the idle row — both fields omitted
1084
1120
  // when unknown, so a session that has not called the model paints
1085
1121
  // exactly the pre-round row.
1086
- dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), agentModel, displayCtxRatio(session), {
1122
+ dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), statusModelLabel(session), displayCtxRatio(session), {
1087
1123
  cacheHitPct: cacheHitPct(runUsage),
1088
1124
  costUsd: spentUsd,
1089
1125
  }));
package/dist/config.js CHANGED
@@ -31,6 +31,7 @@ import { readFileSync } from "node:fs";
31
31
  import { join } from "node:path";
32
32
  import { kisoHome } from "./state.js";
33
33
  import { AuthError, getCredential, providerIdOf } from "./auth/credentials.js";
34
+ import { BUILTIN_MANIFESTS } from "@vincemakes/kiso-runtime/internal";
34
35
  const KINDS = ["openai-compat", "anthropic", "openai-responses"];
35
36
  const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass"];
36
37
  export class ConfigError extends Error {
@@ -209,6 +210,15 @@ export function authForProfile(name, p) {
209
210
  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)"}`);
210
211
  }
211
212
  }
213
+ // OR-2 (2026-09-09): a provider whose manifest takes NO API key (the ChatGPT
214
+ // backend: authMethods ["oauth"]) is never an "unauthenticated endpoint" —
215
+ // with nothing stored it is a profile waiting for its sign-in. Without this
216
+ // the keyless rule below handed it a placeholder key, `/model` said
217
+ // "available", and the adapter (an apiKey selects the first-party target)
218
+ // would have sent an unauthenticated request to the wrong URL.
219
+ if (providerId !== null && !manifestTakesKey(providerId)) {
220
+ throw new ConfigError(`model ${name}: unavailable — not signed in: run \`kiso login ${providerId}\` (this provider takes no API key)`);
221
+ }
212
222
  if (p.apiKeyEnv === undefined)
213
223
  return { type: "api-key", apiKey: "none", source: "none" };
214
224
  const fromEnv = process.env[p.apiKeyEnv];
@@ -217,6 +227,13 @@ export function authForProfile(name, p) {
217
227
  const hint = providerId !== null ? `run \`kiso login ${providerId}\` or set the env var ${p.apiKeyEnv}` : `set the env var ${p.apiKeyEnv}`;
218
228
  throw new ConfigError(`model ${name}: unavailable — no credential: ${hint} (configs never store keys, only the env-var name)`);
219
229
  }
230
+ /** OR-2: does the provider's manifest admit an API key (or no auth at all)?
231
+ * An unknown id answers yes — the keyless-endpoint rule stays for custom
232
+ * origins; only a manifest that says OAuth-only takes the sign-in path. */
233
+ function manifestTakesKey(providerId) {
234
+ const m = BUILTIN_MANIFESTS.find((x) => x.id === providerId);
235
+ return m === undefined || m.authMethods.includes("api-key") || m.authMethods.includes("none");
236
+ }
220
237
  /** The reason a profile is unavailable, for the surfaces that print it. */
221
238
  export function unavailableReason(name, p) {
222
239
  try {
package/dist/dispatch.js CHANGED
@@ -8,7 +8,7 @@ import { newSessionId } from "./session-id.js";
8
8
  import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
9
9
  import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
10
10
  import { clipboardWrite, lastAnswer } from "./clipboard.js";
11
- import { agentModel, body, bodyLog, configModels, dock, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
11
+ import { agentModel, body, bodyLog, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
12
12
  import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
13
13
  /** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
14
14
  * and its default, from the registry, shown wherever the profile is listed
@@ -22,6 +22,43 @@ function effortNote(p) {
22
22
  return "effort: unknown";
23
23
  return `effort: ${effort.levels.map((l) => (l === effort.default ? `[${l}]` : l)).join(" · ")}`;
24
24
  }
25
+ /** OR-7 — the pick panel's second axis, as DATA. `effortNote` above stays
26
+ * the PRINTED list's string (a machine-readable surface whose bytes do
27
+ * not move); the panel gets the levels structured so its cursor can walk
28
+ * them, and so the bracket can mean "the cursor is here" rather than
29
+ * "the registry default".
30
+ *
31
+ * Where the cursor starts: the session's CURRENT effort when this model
32
+ * has that level, else the model's default, else nowhere — a row whose
33
+ * registry default is null (the model page states none) marks nothing,
34
+ * and enter on it applies the profile alone. Nothing is invented. */
35
+ function effortAxis(p) {
36
+ const reasoning = lookupModelMetadata(p.model, p.baseUrl)?.capabilities.reasoning ?? null;
37
+ if (reasoning === null)
38
+ return {};
39
+ const effort = reasoning.effort;
40
+ if (effort === null)
41
+ return {};
42
+ const levels = effort.levels;
43
+ const carried = lastBinding()?.reasoning?.effort;
44
+ const carriedIdx = carried === undefined || carried === "default" ? -1 : levels.indexOf(carried);
45
+ const defaultIdx = effort.default === null ? -1 : levels.indexOf(effort.default);
46
+ const level = carriedIdx >= 0 ? carriedIdx : defaultIdx;
47
+ // the forbidden set is per THINKING mode, and the panel applies its
48
+ // pick as thinking "default" — the same setting `/model <p> <e>`
49
+ // builds — so only pairs forbidden at that mode are dimmed here.
50
+ const disabled = (reasoning.forbidden ?? []).filter((f) => f.thinking === "default").map((f) => levels.indexOf(f.effort)).filter((i) => i >= 0);
51
+ return {
52
+ levels,
53
+ ...(level >= 0 ? { level } : {}),
54
+ ...(disabled.length > 0 ? { disabled } : {}),
55
+ // the sentence the coordination note asked for: said only when the
56
+ // cursor could NOT land where the previous selection asked.
57
+ ...(carried !== undefined && carried !== "default" && carriedIdx < 0 && defaultIdx >= 0
58
+ ? { levelNote: `effort ${carried} \u2192 ${levels[defaultIdx]}: the nearest this model supports` }
59
+ : {}),
60
+ };
61
+ }
25
62
  /** What signs a profile in, for the listing: the env var's name, `oauth`
26
63
  * for a subscription sign-in, or `no key` for an unauthenticated endpoint. */
27
64
  function signInNote(p) {
@@ -316,13 +353,17 @@ export function dispatch(line, ctx) {
316
353
  // surface and this round moves no bytes on one.
317
354
  if (arg === "" && dock.active && ctx.input.panelAsk !== undefined) {
318
355
  const names = Object.keys(configModels);
356
+ let pickedLevel;
319
357
  const picked = await new Promise((resolve) => {
320
358
  ctx.input.panelAsk(modelPickView({
321
359
  header: `model — current: ${agentModel}`,
322
360
  options: names.map((name) => {
323
361
  const profile = configModels[name];
324
- const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : []), effortNote(profile)];
325
- return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · ") };
362
+ // the note keeps what tells two rows apart; the levels
363
+ // left it for the axis below (they were being cut off
364
+ // the end of the note column at 100 columns).
365
+ const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : [])];
366
+ return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · "), ...effortAxis(profile) };
326
367
  }),
327
368
  // PH-1a (finding PH-F4): the example must be a syntax
328
369
  // directWriteProfile actually ACCEPTS — the old
@@ -337,14 +378,30 @@ export function dispatch(line, ctx) {
337
378
  // DC-12 (design §4): a panel WAITING ON A HUMAN says ❯. The
338
379
  // else-branch keeps ▸ — it names the current tier, which is
339
380
  // what ▸ means everywhere else.
340
- ctx.isRunning() ? "❯ run paused" : `▸ ${getMode()}`), (v) => resolve(v.action === "picked" ? v.result : null));
381
+ ctx.isRunning() ? "❯ run paused" : `▸ ${getMode()}`), (v) => {
382
+ // OR-7: the level rides beside the result, so a pick
383
+ // with no second axis is byte-for-byte the old one.
384
+ if (v.action === "picked")
385
+ pickedLevel = v.level;
386
+ resolve(v.action === "picked" ? v.result : null);
387
+ });
341
388
  });
342
389
  ctx.paintIdle();
343
390
  if (picked === null) {
344
391
  ctx.input.prompt();
345
392
  return; // esc — nothing switched, nothing said
346
393
  }
347
- arg = "index" in picked ? names[picked.index] : picked.custom;
394
+ if ("index" in picked) {
395
+ // OR-7: the panel hands back both axes and the command
396
+ // string is where they meet — everything below is the
397
+ // typed `/model <profile> <effort>` path, unchanged, so
398
+ // the refusal wording and the durable revision are its.
399
+ const name = names[picked.index];
400
+ const level = pickedLevel === undefined ? undefined : effortAxis(configModels[name]).levels?.[pickedLevel];
401
+ arg = level === undefined ? name : `${name} ${level}`;
402
+ }
403
+ else
404
+ arg = picked.custom;
348
405
  }
349
406
  if (arg === "") {
350
407
  bodyLog(`model: ${agentModel}`);
@@ -373,7 +430,13 @@ export function dispatch(line, ctx) {
373
430
  bodyLog(`no such model profile: ${profName}`);
374
431
  }
375
432
  else if (!profileAvailable(profile)) {
376
- bodyLog(unavailableReason(arg, profile));
433
+ // the PROFILE name, not the whole argument: what is
434
+ // unavailable is the credential, and an effort token
435
+ // riding along ("claude high: unavailable") reads as
436
+ // though the level were part of the problem. The panel
437
+ // began producing that token in OR-7; the typed path
438
+ // could always produce it too.
439
+ bodyLog(unavailableReason(profName, profile));
377
440
  }
378
441
  else {
379
442
  const auth = authForProfile(profName, profile);
@@ -430,6 +493,12 @@ export function dispatch(line, ctx) {
430
493
  setLastBinding(binding);
431
494
  setAgentModel(profile.model, profile.baseUrl);
432
495
  setCurrentModelName(arg);
496
+ // OR-7 (owner, 2026-09-09): the status row is repainted BY the
497
+ // switch — it used to keep the old model until the next
498
+ // recap repainted it, so "takes effect on the next turn"
499
+ // read as "did not take". Painted BEFORE the notice so the
500
+ // frame that carries the notice already carries the row.
501
+ ctx.paintIdle();
433
502
  body.notice(`model → ${profName} (${profile.model}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
434
503
  }
435
504
  }
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@
24
24
  * makeAgent, and main.
25
25
  */
26
26
  import { appendFileSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
27
+ import { spawn, spawnSync } from "node:child_process";
27
28
  import { newSessionId } from "./session-id.js";
28
29
  import { createInterface } from "node:readline";
29
30
  import { Readable } from "node:stream";
@@ -39,10 +40,10 @@ import { agentModel, atFiles, body, bodyLog, kisoHome, builtInExtensions, curren
39
40
  import { askUi, resolveProjectTrust } from "./trust-ui.js";
40
41
  import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
41
42
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
42
- import { chat, contextWindowTokens, displayCtxRatio } from "./chat.js";
43
+ import { chat, contextWindowTokens, displayCtxRatio, statusModelLabel } from "./chat.js";
43
44
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
44
45
  import { oauthTokenThunk } from "./auth/token.js";
45
- import { checkForUpdate } from "./update-check.js";
46
+ import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
46
47
  import { resume } from "./resume.js";
47
48
  import { resumeTail } from "./resume-tail.js";
48
49
  import { armByteTrace } from "./byte-trace.js";
@@ -62,6 +63,29 @@ export { applyProjectMerges } from "./trust-ui.js";
62
63
  * exits, so the turn runs to its terminal and nothing else is read.
63
64
  * Built on an EMPTY readline so every other member keeps the pipe
64
65
  * path's exact semantics (auto-denied asks, no chips, no pops). */
66
+ /** OR-4: hand a URL to the platform's opener, detached, and never let a
67
+ * missing or failing opener touch the sign-in — the printed URL is the
68
+ * path that always works. macOS `open`, Linux `xdg-open`; elsewhere the
69
+ * URL stays printed (Windows is unsupported by the CLI as a whole). */
70
+ function openInBrowser(url) {
71
+ const opener = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
72
+ if (opener === null)
73
+ return;
74
+ try {
75
+ spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
76
+ }
77
+ catch {
78
+ // the URL is on screen; opening it is the person's fallback
79
+ }
80
+ }
81
+ /** OR-3: the commands that own their own stdin reader (a hidden key prompt,
82
+ * an OAuth paste prompt) or read nothing at all — never the editor.
83
+ * OR-9 adds `update`: npm inherits the terminal for the install's own
84
+ * progress and prompts, so no editor may hold stdin while it runs. */
85
+ const CREDENTIAL_COMMANDS = new Set(["login", "logout", "auth", "update"]);
86
+ /** OR-9: the one install command the README gives, which `kiso update`
87
+ * runs and the failure message names. */
88
+ const INSTALL_COMMAND = ["npm", "i", "-g", "@vincemakes/kiso-code@latest"];
65
89
  function taskFileInput(content) {
66
90
  const base = readlineInput(createInterface({ input: Readable.from([]), output: process.stdout }));
67
91
  return {
@@ -384,28 +408,42 @@ function bannerExtensionText() {
384
408
  * resize; the resume list re-gates with the tier); off-TTY: the
385
409
  * historical `[N extensions: ...]` standalone line (zero change). */
386
410
  /**
387
- * The update line, when there is one, appended after the opening.
411
+ * The update card, when there is one, appended after the opening.
388
412
  *
389
- * It never blocks: the caller does not await it, and the banner is
390
- * already drawn when this starts. If the check fails, times out, is
391
- * switched off, or finds nothing newer, this does nothing at all and
392
- * says nothing about having tried.
413
+ * Two painters, one card. The boot paints what the CACHE already knows,
414
+ * synchronously, so a known newer version is under the banner on the
415
+ * first frame; the check then runs fired-and-forgotten (at most one
416
+ * request a day) and paints only a version the boot did not. If the check
417
+ * fails, times out, is switched off, or finds nothing newer, it does
418
+ * nothing at all and says nothing about having tried.
393
419
  */
420
+ /** OR-9: the version this process has already carded — the boot's cache
421
+ * paint and the async check's answer never both card one version. */
422
+ let updateShown = null;
423
+ const updateDeps = () => ({ kisoHome: kisoHome(), version: VERSION, isTTY: process.stdout.isTTY === true, faux: currentFaux });
424
+ /** OR-9 (owner, 2026-09-09): the update is a CARD under the banner — a
425
+ * rule, a bold title, the version with the command that installs it, the
426
+ * changelog, a rule — the shape the reference shows at every start. The
427
+ * RAW channel (not `notice`, which strips SGR through escapeTerminal); a
428
+ * raw cell lands at the transcript's end, never spliced mid-frame. A
429
+ * declared exception to R2 law 1.1 (a notice wears no edge): a card is
430
+ * not a notice, and the owner asked for the edges. */
431
+ function paintUpdateCard(latest) {
432
+ if (latest === updateShown)
433
+ return;
434
+ updateShown = latest;
435
+ const p = palette();
436
+ const width = Math.min(process.stdout.columns ?? 80, 80);
437
+ const rule = `${p.dim}${"─".repeat(width)}${p.reset}`;
438
+ const [title, line, changelog] = updateCardLines(latest);
439
+ bodyLog(`${rule}\n${p.bold}${title}${p.reset}\n${line}\n${changelog}\n${rule}`, "words");
440
+ }
394
441
  async function announceUpdate() {
395
442
  try {
396
- const line = await checkForUpdate({ kisoHome: kisoHome(), version: VERSION, isTTY: process.stdout.isTTY === true, faux: currentFaux });
397
- if (line === null)
443
+ const latest = await checkForUpdate(updateDeps());
444
+ if (latest === null)
398
445
  return;
399
- // THE RAW CHANNEL, not `notice`. A notice renders through ErrorLine,
400
- // which calls `escapeTerminal` — so every SGR in it is stripped and
401
- // the line could only ever be full-strength prose. The raw channel
402
- // is the one the banner itself uses: the styling is applied HERE, at
403
- // composition, and the fold keeps the span intact (#16b).
404
- //
405
- // A turn may have started while the request was in flight. A raw
406
- // cell is a cell like any other, so it lands at the transcript's
407
- // end — never spliced into a frame the reader is mid-way through.
408
- bodyLog(`${palette().dim}${line}${palette().reset}`, "words");
446
+ paintUpdateCard(latest);
409
447
  }
410
448
  catch {
411
449
  // belt and braces: checkForUpdate does not throw, and if it ever
@@ -742,7 +780,7 @@ async function pickSession(agent, input) {
742
780
  function paintBootStatus(session) {
743
781
  if (!dock.active)
744
782
  return;
745
- dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), agentModel, displayCtxRatio(session)));
783
+ dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), statusModelLabel(session), displayCtxRatio(session)));
746
784
  }
747
785
  /** PH-1a (finding PH-F12): a usage error raised from inside the TUI —
748
786
  * main's finally still runs (dock teardown, agent close, temp cleanup)
@@ -778,11 +816,16 @@ async function chatLoop(agent, firstId, input, autoCompact) {
778
816
  // throws on profile drift — so one drifted session anywhere in the
779
817
  // history stopped kiso from starting at all.
780
818
  extensionsBanner();
781
- // The one line the opening may gain (§7.10's ruling): "there is
782
- // a newer kiso". FIRED AND FORGOTTEN — the banner above is
783
- // already on screen and this never delays it. When the answer
784
- // arrives it rides `body.notice`, the commit path notices
785
- // already use, rather than a second channel into the frame.
819
+ // OR-9 (owner, 2026-09-09): the update card under the banner, at
820
+ // EVERY start while a newer version is known — §7.10's once-only
821
+ // line is superseded. What the cache knows is painted NOW, with
822
+ // no request, so the card is part of the opening rather than a
823
+ // late arrival; the check itself stays FIRED AND FORGOTTEN — the
824
+ // banner is already on screen and it never delays it — and it
825
+ // paints only a version the boot did not know.
826
+ const known = knownUpdate(updateDeps());
827
+ if (known !== null)
828
+ paintUpdateCard(known);
786
829
  void announceUpdate();
787
830
  }
788
831
  else {
@@ -830,10 +873,15 @@ async function readSecret(prompt) {
830
873
  process.stdin.resume();
831
874
  });
832
875
  }
833
- process.stdout.write(prompt);
834
876
  return await new Promise((resolve) => {
835
877
  let buf = "";
878
+ // OR-3: raw mode BEFORE the prompt — the hidden prompt must be hidden
879
+ // before it is shown. Written the other way round, a key that arrives
880
+ // between the prompt and setRawMode is echoed by the tty line
881
+ // discipline (the auth-tty gate caught it: a driver that types the
882
+ // instant the prompt appears saw the key on screen two runs in three).
836
883
  process.stdin.setRawMode(true);
884
+ process.stdout.write(prompt);
837
885
  process.stdin.resume();
838
886
  process.stdin.setEncoding("utf8");
839
887
  const onData = (chunk) => {
@@ -975,7 +1023,20 @@ async function main() {
975
1023
  // (entered here, dock-bound, trusted before any extension loads),
976
1024
  // readline elsewhere. The trust question, chat, and resume all read
977
1025
  // through it; main's finally closes it on every exit path.
978
- const input = taskFile !== undefined ? taskFileInput(taskFile) : makeLineInput();
1026
+ // OR-3 (owner, 2026-09-09): the credential commands never read the shared
1027
+ // line input, and on a TTY makeLineInput() enters the raw-mode editor and
1028
+ // sends the ground probe (CSI ?996n + OSC 11) BEFORE the command switch.
1029
+ // `kiso login chatgpt` then had two readers on stdin: the editor swallowed
1030
+ // the keystrokes, the terminal's OSC reply was typed into the login's own
1031
+ // readline, the "open this URL" line vanished under the dock's repaint,
1032
+ // and the sign-in never completed. These commands get an input over an
1033
+ // EMPTY readable instead — no editor, no probe, stdin untouched — so the
1034
+ // hidden key prompt and the OAuth paste prompt are the only readers.
1035
+ const input = taskFile !== undefined
1036
+ ? taskFileInput(taskFile)
1037
+ : CREDENTIAL_COMMANDS.has(command ?? "")
1038
+ ? readlineInput(createInterface({ input: Readable.from([]), output: process.stdout }))
1039
+ : makeLineInput();
979
1040
  // R3a — cross-session input history: ~/.kiso/history, one line per
980
1041
  // entry, appended on submit, tail-500 at load (truncated by REWRITE
981
1042
  // at startup so the file never grows unbounded). Unreadable file =
@@ -1195,6 +1256,9 @@ async function main() {
1195
1256
  const cred = await chatgptFlow.login({
1196
1257
  notify: (line) => process.stdout.write(`${line}\n`),
1197
1258
  prompt: (q) => new Promise((resolve) => rl.question(q, resolve)),
1259
+ // OR-4: a person at a terminal gets the browser opened for
1260
+ // them; a pipe, a test rig or KISO_NO_BROWSER=1 gets the URL only.
1261
+ ...(process.stdout.isTTY === true && process.env.KISO_NO_BROWSER === undefined ? { open: openInBrowser } : {}),
1198
1262
  ...(cbPort !== undefined ? { callback: { host: process.env.KISO_OAUTH_CALLBACK_HOST ?? "127.0.0.1", port: cbPort } } : {}),
1199
1263
  ...(process.env.KISO_OAUTH_TOKEN_URL !== undefined ? { tokenUrl: process.env.KISO_OAUTH_TOKEN_URL } : {}),
1200
1264
  });
@@ -1213,6 +1277,27 @@ async function main() {
1213
1277
  process.stdout.write(`signed in to ${provider} with an API key (${maskSecret(key)}) — stored in ${authPath()}\n`);
1214
1278
  break;
1215
1279
  }
1280
+ case "update": {
1281
+ // OR-9: the command the update card names. A thin hand-off to
1282
+ // the ONE install command the README gives — npm resolved from
1283
+ // PATH, its stdio inherited so its progress, its prompts and
1284
+ // its own errors are what the person sees, its exit code the
1285
+ // outcome. kiso adds nothing to what npm knows: no version
1286
+ // pinning, no registry, no elevation. A failure (a global
1287
+ // prefix this user cannot write, a network that is down) is
1288
+ // npm's to explain; the message names the manual command so
1289
+ // the person can run it with whatever their setup needs.
1290
+ const [bin, ...installArgs] = INSTALL_COMMAND;
1291
+ const r = spawnSync(bin, installArgs, { stdio: "inherit" });
1292
+ if (r.error !== undefined || r.status !== 0) {
1293
+ const why = r.error !== undefined ? `could not run npm (${r.error.message})` : `npm exited ${r.status ?? "on a signal"}`;
1294
+ process.stderr.write(`kiso update: ${why} — run it yourself: ${INSTALL_COMMAND.join(" ")}\n`);
1295
+ process.exitCode = r.status ?? 1;
1296
+ break;
1297
+ }
1298
+ process.stdout.write("kiso updated — the next `kiso` runs the new version\n");
1299
+ break;
1300
+ }
1216
1301
  case "help": {
1217
1302
  // PH-1b (finding PH-F21): the CLI must be able to describe its
1218
1303
  // own configuration — the old help listed five commands and
@@ -1230,6 +1315,7 @@ async function main() {
1230
1315
  " chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
1231
1316
  " kiso logout <provider> remove the stored credential\n" +
1232
1317
  " kiso auth list stored credentials (keys masked)\n" +
1318
+ " kiso update install the latest release (npm i -g @vincemakes/kiso-code@latest)\n" +
1233
1319
  " kiso help this help\n\n" +
1234
1320
  "flags (any position):\n" +
1235
1321
  " --model <profile|provider/model> pick the model (also /model in-session)\n" +
@@ -32,8 +32,6 @@ export interface UpdateCache {
32
32
  readonly checkedAt: number;
33
33
  /** the latest version the registry reported, if the check succeeded */
34
34
  readonly latest?: string;
35
- /** the version already mentioned to this human — never mentioned twice */
36
- readonly told?: string;
37
35
  }
38
36
  /**
39
37
  * Semver-ish "is b newer than a", for the shapes this line ever sees:
@@ -44,7 +42,15 @@ export interface UpdateCache {
44
42
  export declare function isNewer(current: string, latest: string): boolean;
45
43
  /** The one line, when there is one. Words only — a human can read it,
46
44
  * paste it, and nothing about it needs colour to be understood (§1.2). */
47
- export declare const updateLine: (latest: string) => string;
45
+ export declare const CHANGELOG_URL = "https://github.com/vincemakes/kiso/releases";
46
+ /** OR-9 (owner, 2026-09-09): the card under the banner, as WORDS — a
47
+ * title, the version with the command that installs it, the changelog.
48
+ * The CLI styles them at composition; nothing here needs colour. */
49
+ export declare function updateCardLines(latest: string): readonly string[];
50
+ /** OR-9: what the cache already knows, with NO request — the boot paints
51
+ * it under the banner before any check runs. Same silences as the check
52
+ * (opt-out, faux without a seam, a pipe); a merely-recent cache speaks. */
53
+ export declare function knownUpdate(deps: UpdateCheckDeps): string | null;
48
54
  export interface UpdateCheckDeps {
49
55
  readonly kisoHome: string;
50
56
  readonly version: string;
@@ -51,7 +51,6 @@ function readCache(kisoHome) {
51
51
  return {
52
52
  checkedAt: c.checkedAt,
53
53
  ...(typeof c.latest === "string" ? { latest: c.latest } : {}),
54
- ...(typeof c.told === "string" ? { told: c.told } : {}),
55
54
  };
56
55
  }
57
56
  catch {
@@ -97,7 +96,23 @@ export function isNewer(current, latest) {
97
96
  }
98
97
  /** The one line, when there is one. Words only — a human can read it,
99
98
  * 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`;
99
+ export const CHANGELOG_URL = "https://github.com/vincemakes/kiso/releases";
100
+ /** OR-9 (owner, 2026-09-09): the card under the banner, as WORDS — a
101
+ * title, the version with the command that installs it, the changelog.
102
+ * The CLI styles them at composition; nothing here needs colour. */
103
+ export function updateCardLines(latest) {
104
+ return ["Update available", `New version ${latest} is available. Run kiso update`, `Changelog: ${CHANGELOG_URL}`];
105
+ }
106
+ /** OR-9: what the cache already knows, with NO request — the boot paints
107
+ * it under the banner before any check runs. Same silences as the check
108
+ * (opt-out, faux without a seam, a pipe); a merely-recent cache speaks. */
109
+ export function knownUpdate(deps) {
110
+ const why = reasonNotToCheck(deps);
111
+ if (why !== null && why !== "recent")
112
+ return null;
113
+ const cached = readCache(deps.kisoHome);
114
+ return cached?.latest !== undefined && isNewer(deps.version, cached.latest) ? cached.latest : null;
115
+ }
101
116
  /**
102
117
  * Should this launch even ask? Pure, so the reasons are testable without
103
118
  * a network, a clock or a terminal.
@@ -142,21 +157,14 @@ function reasonNotToCheck({ kisoHome, isTTY, faux, now = Date.now() }) {
142
157
  export async function checkForUpdate(deps) {
143
158
  const { kisoHome, version, now = Date.now() } = deps;
144
159
  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
+ // ONLY a recent check may still speak from cache. The other three
161
+ // reasons are about the CARD, not the request, so the cache is no way
162
+ // around them. OR-9: no `told` gate — a newer version is known at
163
+ // EVERY start (the card is shown every start; the once-only rule of
164
+ // §7.10 is superseded by the owner's 2026-09-09 ruling), only the
165
+ // REQUEST stays at most once a day.
166
+ if (why !== null)
167
+ return why === "recent" ? knownUpdate(deps) : null;
160
168
  let latest;
161
169
  try {
162
170
  const ctl = new AbortController();
@@ -177,15 +185,6 @@ export async function checkForUpdate(deps) {
177
185
  catch {
178
186
  // every failure is silence — see the header
179
187
  }
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;
188
+ writeCache(kisoHome, { checkedAt: now, ...(latest === undefined ? {} : { latest }) });
189
+ return latest !== undefined && isNewer(version, latest) ? latest : null;
191
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
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,20 +18,20 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-ask-ext": "0.31.0",
22
- "@vincemakes/kiso-core": "0.31.0",
23
- "@vincemakes/kiso-evals": "0.31.0",
24
- "@vincemakes/kiso-mcp-ext": "0.31.0",
25
- "@vincemakes/kiso-provider-anthropic": "0.31.0",
26
- "@vincemakes/kiso-provider-openai": "0.31.0",
27
- "@vincemakes/kiso-provider-openai-responses": "0.31.0",
28
- "@vincemakes/kiso-runtime": "0.31.0",
29
- "@vincemakes/kiso-skills-ext": "0.31.0",
30
- "@vincemakes/kiso-subagent-ext": "0.31.0",
31
- "@vincemakes/kiso-task-ext": "0.31.0",
32
- "@vincemakes/kiso-tools-node": "0.31.0",
33
- "@vincemakes/kiso-tui": "0.31.0",
34
- "@vincemakes/kiso-tui-cells": "0.31.0"
21
+ "@vincemakes/kiso-ask-ext": "0.31.1",
22
+ "@vincemakes/kiso-core": "0.31.1",
23
+ "@vincemakes/kiso-evals": "0.31.1",
24
+ "@vincemakes/kiso-mcp-ext": "0.31.1",
25
+ "@vincemakes/kiso-provider-anthropic": "0.31.1",
26
+ "@vincemakes/kiso-provider-openai": "0.31.1",
27
+ "@vincemakes/kiso-provider-openai-responses": "0.31.1",
28
+ "@vincemakes/kiso-runtime": "0.31.1",
29
+ "@vincemakes/kiso-skills-ext": "0.31.1",
30
+ "@vincemakes/kiso-subagent-ext": "0.31.1",
31
+ "@vincemakes/kiso-task-ext": "0.31.1",
32
+ "@vincemakes/kiso-tools-node": "0.31.1",
33
+ "@vincemakes/kiso-tui": "0.31.1",
34
+ "@vincemakes/kiso-tui-cells": "0.31.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^26.1.2",
package/dist/diff.d.ts DELETED
@@ -1,32 +0,0 @@
1
- /**
2
- * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
6
- *
7
- * edit_file diffs IN PLACE (the search→replace windows are known — no
8
- * general engine needed); write_file does a row-level LCS over the old
9
- * file (small files are the target). Context: 2 rows each side. The
10
- * RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
11
- * from the full diff.
12
- */
13
- /** The diff block's per-row kind. */
14
- export type DiffLine = {
15
- kind: "-" | "+" | " ";
16
- text: string;
17
- };
18
- export interface DiffResult {
19
- /** The FULL diff (with context, not truncated) — the display truncates. */
20
- lines: DiffLine[];
21
- added: number;
22
- removed: number;
23
- }
24
- /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
25
- export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
26
- /** edit_file: the search→replace windows replace in place — the changed
27
- * region is KNOWN, so the diff is the old window vs the new window,
28
- * context from the surrounding file. */
29
- export declare function editFileDiff(oldContent: string, search: string, replace: string): DiffResult;
30
- /** write_file: a new file is all +; an existing file diffs row-level
31
- * against its old content. */
32
- export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;
package/dist/diff.js DELETED
@@ -1,122 +0,0 @@
1
- /**
2
- * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
6
- *
7
- * edit_file diffs IN PLACE (the search→replace windows are known — no
8
- * general engine needed); write_file does a row-level LCS over the old
9
- * file (small files are the target). Context: 2 rows each side. The
10
- * RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
11
- * from the full diff.
12
- */
13
- /** A line-level LCS diff — the classic two-row DP, ~small inputs. */
14
- function lcsDiff(oldLines, newLines) {
15
- const n = oldLines.length;
16
- const m = newLines.length;
17
- const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
18
- for (let i = n - 1; i >= 0; i -= 1) {
19
- for (let j = m - 1; j >= 0; j -= 1) {
20
- dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
21
- }
22
- }
23
- const out = [];
24
- let i = 0;
25
- let j = 0;
26
- while (i < n && j < m) {
27
- if (oldLines[i] === newLines[j]) {
28
- out.push({ kind: " ", text: oldLines[i] });
29
- i += 1;
30
- j += 1;
31
- }
32
- else if (dp[i + 1][j] >= dp[i][j + 1]) {
33
- out.push({ kind: "-", text: oldLines[i] });
34
- i += 1;
35
- }
36
- else {
37
- out.push({ kind: "+", text: newLines[j] });
38
- j += 1;
39
- }
40
- }
41
- while (i < n) {
42
- out.push({ kind: "-", text: oldLines[i] });
43
- i += 1;
44
- }
45
- while (j < m) {
46
- out.push({ kind: "+", text: newLines[j] });
47
- j += 1;
48
- }
49
- return out;
50
- }
51
- /** Keep 2 context rows around each change — the unified-style window. */
52
- function withContext(diff) {
53
- const out = [];
54
- let lastAdded = -10;
55
- for (let k = 0; k < diff.length; k += 1) {
56
- if (diff[k].kind === " ")
57
- continue;
58
- const from = Math.max(0, k - 2);
59
- const to = Math.min(diff.length - 1, k + 2);
60
- for (let c = from; c <= to; c += 1) {
61
- if (c > lastAdded) {
62
- out.push(diff[c]);
63
- lastAdded = c;
64
- }
65
- }
66
- lastAdded = to;
67
- }
68
- return out;
69
- }
70
- const MAX_DIFF_LINES = 40; // the RENDERED cap
71
- const TRUNCATE_KEEP = 18;
72
- /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
73
- export function truncateDiff(diff) {
74
- if (diff.length <= MAX_DIFF_LINES)
75
- return diff;
76
- const omitted = diff.length - 2 * TRUNCATE_KEEP;
77
- return [
78
- ...diff.slice(0, TRUNCATE_KEEP),
79
- { kind: " ", text: `… ${omitted} lines (/last for full)` },
80
- ...diff.slice(diff.length - TRUNCATE_KEEP),
81
- ];
82
- }
83
- function stats(diff) {
84
- let added = 0;
85
- let removed = 0;
86
- for (const d of diff) {
87
- if (d.kind === "+")
88
- added += 1;
89
- else if (d.kind === "-")
90
- removed += 1;
91
- }
92
- return { added, removed };
93
- }
94
- /** edit_file: the search→replace windows replace in place — the changed
95
- * region is KNOWN, so the diff is the old window vs the new window,
96
- * context from the surrounding file. */
97
- export function editFileDiff(oldContent, search, replace) {
98
- const oldLines = oldContent.split("\n");
99
- const searchLines = search.split("\n");
100
- const replaceLines = replace.split("\n");
101
- // Locate the search window (the first occurrence — the edit tool's own
102
- // semantics); no occurrence → the whole file is the old side.
103
- let at = -1;
104
- for (let i = 0; i + searchLines.length <= oldLines.length; i += 1) {
105
- if (oldLines.slice(i, i + searchLines.length).join("\n") === search) {
106
- at = i;
107
- break;
108
- }
109
- }
110
- const lines = at < 0 ? withContext(lcsDiff(oldLines, replaceLines)) : withContext(lcsDiff(oldLines, [...oldLines.slice(0, at), ...replaceLines, ...oldLines.slice(at + searchLines.length)]));
111
- return { lines, ...stats(lines) };
112
- }
113
- /** write_file: a new file is all +; an existing file diffs row-level
114
- * against its old content. */
115
- export function writeFileDiff(oldContent, newContent) {
116
- if (oldContent === null) {
117
- const lines = newContent.split("\n").map((text) => ({ kind: "+", text }));
118
- return { lines, added: lines.length, removed: 0 };
119
- }
120
- const lines = withContext(lcsDiff(oldContent.split("\n"), newContent.split("\n")));
121
- return { lines, ...stats(lines) };
122
- }