@vincemakes/kiso-code 0.30.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.
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,9 +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
- import { checkForUpdate } from "./update-check.js";
45
+ import { oauthTokenThunk } from "./auth/token.js";
46
+ import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
45
47
  import { resume } from "./resume.js";
46
48
  import { resumeTail } from "./resume-tail.js";
47
49
  import { armByteTrace } from "./byte-trace.js";
@@ -61,6 +63,29 @@ export { applyProjectMerges } from "./trust-ui.js";
61
63
  * exits, so the turn runs to its terminal and nothing else is read.
62
64
  * Built on an EMPTY readline so every other member keeps the pipe
63
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"];
64
89
  function taskFileInput(content) {
65
90
  const base = readlineInput(createInterface({ input: Readable.from([]), output: process.stdout }));
66
91
  return {
@@ -383,28 +408,42 @@ function bannerExtensionText() {
383
408
  * resize; the resume list re-gates with the tier); off-TTY: the
384
409
  * historical `[N extensions: ...]` standalone line (zero change). */
385
410
  /**
386
- * The update line, when there is one, appended after the opening.
411
+ * The update card, when there is one, appended after the opening.
387
412
  *
388
- * It never blocks: the caller does not await it, and the banner is
389
- * already drawn when this starts. If the check fails, times out, is
390
- * switched off, or finds nothing newer, this does nothing at all and
391
- * 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.
392
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
+ }
393
441
  async function announceUpdate() {
394
442
  try {
395
- const line = await checkForUpdate({ kisoHome: kisoHome(), version: VERSION, isTTY: process.stdout.isTTY === true, faux: currentFaux });
396
- if (line === null)
443
+ const latest = await checkForUpdate(updateDeps());
444
+ if (latest === null)
397
445
  return;
398
- // THE RAW CHANNEL, not `notice`. A notice renders through ErrorLine,
399
- // which calls `escapeTerminal` — so every SGR in it is stripped and
400
- // the line could only ever be full-strength prose. The raw channel
401
- // is the one the banner itself uses: the styling is applied HERE, at
402
- // composition, and the fold keeps the span intact (#16b).
403
- //
404
- // A turn may have started while the request was in flight. A raw
405
- // cell is a cell like any other, so it lands at the transcript's
406
- // end — never spliced into a frame the reader is mid-way through.
407
- bodyLog(`${palette().dim}${line}${palette().reset}`, "words");
446
+ paintUpdateCard(latest);
408
447
  }
409
448
  catch {
410
449
  // belt and braces: checkForUpdate does not throw, and if it ever
@@ -607,7 +646,7 @@ async function makeAgent(sessionId, input, modelFlag) {
607
646
  setCurrentFaux(false);
608
647
  setCurrentModelName(resolved.name);
609
648
  }
610
- setAgentModel(model); // v2b: the status bar shows it
649
+ setAgentModel(model, resolved?.profile.baseUrl); // v2b: the status bar shows it; OR-1: the endpoint rides along
611
650
  // W21: the extensions array is built ONCE per agent and shared with
612
651
  // the runtime by reference — the don't-ask-again writer pushes the
613
652
  // generated extension into it so a first-time rule joins the chain
@@ -647,13 +686,24 @@ async function makeAgent(sessionId, input, modelFlag) {
647
686
  // field survives for the callers that want a bound (subagents, the
648
687
  // SDK, `kiso run`); the interactive front door does not set one.
649
688
  // Modes: the five tiers join at the CHAIN HEAD, before the user/
650
- // project extensions (the deny>ask>allow composition keeps a user
689
+ // project extensions (the deny>allow>ask composition keeps a user
651
690
  // deny winning over any mode tier — bypass included).
652
691
  extensions,
653
692
  ...(resolved !== null
654
693
  ? {
655
694
  provider: resolved.profile.kind,
656
- apiKey: resolved.apiKey,
695
+ // OR-1: exactly one sign-in shape reaches the adapter. An
696
+ // OAuth profile has no key to pass — it passes the thunk
697
+ // the adapter re-resolves per request instead.
698
+ ...(resolved.oauthProviderId !== undefined
699
+ ? { oauth: oauthTokenThunk(resolved.oauthProviderId) }
700
+ : { apiKey: resolved.apiKey ?? "none" }),
701
+ // OR-1: the ChatGPT backend's cache lane is the SESSION —
702
+ // one conversation's requests share a key, different
703
+ // conversations never do. The one entry point that hands
704
+ // no session id is `kiso sessions`, a read-only listing
705
+ // that streams nothing, so its absence costs no cache.
706
+ ...(sessionId !== undefined ? { promptCacheKey: sessionId } : {}),
657
707
  ...(resolved.profile.baseUrl !== undefined ? { baseUrl: resolved.profile.baseUrl } : {}),
658
708
  ...(resolved.profile.promptCaching !== undefined ? { promptCaching: resolved.profile.promptCaching } : {}),
659
709
  }
@@ -730,7 +780,7 @@ async function pickSession(agent, input) {
730
780
  function paintBootStatus(session) {
731
781
  if (!dock.active)
732
782
  return;
733
- 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)));
734
784
  }
735
785
  /** PH-1a (finding PH-F12): a usage error raised from inside the TUI —
736
786
  * main's finally still runs (dock teardown, agent close, temp cleanup)
@@ -766,11 +816,16 @@ async function chatLoop(agent, firstId, input, autoCompact) {
766
816
  // throws on profile drift — so one drifted session anywhere in the
767
817
  // history stopped kiso from starting at all.
768
818
  extensionsBanner();
769
- // The one line the opening may gain (§7.10's ruling): "there is
770
- // a newer kiso". FIRED AND FORGOTTEN — the banner above is
771
- // already on screen and this never delays it. When the answer
772
- // arrives it rides `body.notice`, the commit path notices
773
- // 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);
774
829
  void announceUpdate();
775
830
  }
776
831
  else {
@@ -789,7 +844,7 @@ async function chatLoop(agent, firstId, input, autoCompact) {
789
844
  // XP-1 §2.1: the switched-to session's OWN truth repaints the row —
790
845
  // the global display state never outlives the session it described
791
846
  // (pre-XP the row kept the previous session's /model selection).
792
- setAgentModel(session.model);
847
+ setAgentModel(session.model, session.baseUrl);
793
848
  setCurrentModelName(session.model);
794
849
  paintBootStatus(session);
795
850
  const nav = {
@@ -803,6 +858,56 @@ async function chatLoop(agent, firstId, input, autoCompact) {
803
858
  id = end.id;
804
859
  }
805
860
  }
861
+ /** A secret from the terminal: hidden input on a TTY, the whole of stdin
862
+ * otherwise (a pipe, a heredoc) — never an argument, which shell history
863
+ * would keep. */
864
+ async function readSecret(prompt) {
865
+ if (process.stdin.isTTY !== true) {
866
+ return await new Promise((resolve) => {
867
+ let buf = "";
868
+ process.stdin.setEncoding("utf8");
869
+ process.stdin.on("data", (d) => {
870
+ buf += d;
871
+ });
872
+ process.stdin.on("end", () => resolve(buf.split("\n")[0] ?? ""));
873
+ process.stdin.resume();
874
+ });
875
+ }
876
+ return await new Promise((resolve) => {
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).
883
+ process.stdin.setRawMode(true);
884
+ process.stdout.write(prompt);
885
+ process.stdin.resume();
886
+ process.stdin.setEncoding("utf8");
887
+ const onData = (chunk) => {
888
+ for (const ch of chunk) {
889
+ if (ch === "\r" || ch === "\n") {
890
+ process.stdin.setRawMode(false);
891
+ process.stdin.pause();
892
+ process.stdin.off("data", onData);
893
+ process.stdout.write("\n");
894
+ resolve(buf);
895
+ return;
896
+ }
897
+ if (ch === "\u0003") {
898
+ process.stdin.setRawMode(false);
899
+ process.stdout.write("\n");
900
+ process.exit(130);
901
+ }
902
+ if (ch === "\u007f" || ch === "\b")
903
+ buf = buf.slice(0, -1);
904
+ else
905
+ buf += ch;
906
+ }
907
+ };
908
+ process.stdin.on("data", onData);
909
+ });
910
+ }
806
911
  async function main() {
807
912
  // E group (the graceful-exit gate ③, R-G 0.1.48): a terminal closing
808
913
  // turns the in-flight stdout/stderr writes into EIO, and node's
@@ -918,7 +1023,20 @@ async function main() {
918
1023
  // (entered here, dock-bound, trusted before any extension loads),
919
1024
  // readline elsewhere. The trust question, chat, and resume all read
920
1025
  // through it; main's finally closes it on every exit path.
921
- 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();
922
1040
  // R3a — cross-session input history: ~/.kiso/history, one line per
923
1041
  // entry, appended on submit, tail-500 at load (truncated by REWRITE
924
1042
  // at startup so the file never grows unbounded). Unreadable file =
@@ -1095,6 +1213,91 @@ async function main() {
1095
1213
  }
1096
1214
  break;
1097
1215
  }
1216
+ case "login":
1217
+ case "logout":
1218
+ case "auth": {
1219
+ // the sign-in plan (2026-09-08), step 1: the credential store.
1220
+ // `login <provider>` reads the key from a hidden prompt on a TTY
1221
+ // or from stdin otherwise (never from an argument — shell history);
1222
+ // `logout <provider>` deletes; `auth` lists, keys masked.
1223
+ const { KNOWN_PROVIDERS, OAUTH_PROVIDERS, authPath, deleteCredential, maskSecret, readAuthFile, setCredential } = await import("./auth/credentials.js");
1224
+ const provider = arg;
1225
+ if (command === "auth") {
1226
+ const file = readAuthFile();
1227
+ const rows = Object.entries(file.credentials);
1228
+ const lines = [`credentials: ${authPath()}${rows.length === 0 ? " (none stored)" : ""}`];
1229
+ for (const [id, cred] of rows) {
1230
+ if (cred.type === "api-key")
1231
+ lines.push(` ${id.padEnd(10)} api-key ${maskSecret(cred.key)} saved ${new Date(cred.savedAt).toISOString().slice(0, 10)}`);
1232
+ else
1233
+ lines.push(` ${id.padEnd(10)} oauth ${cred.accountId ?? ""} expires ${new Date(cred.expires).toISOString()}${cred.expires < Date.now() ? " (expired)" : ""}`);
1234
+ }
1235
+ const envs = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].filter((k) => process.env[k] !== undefined);
1236
+ lines.push(`env vars set: ${envs.length ? envs.join(", ") : "none"} (a stored credential owns its provider; env applies only when nothing is stored)`);
1237
+ process.stdout.write(`${lines.join("\n")}\n`);
1238
+ break;
1239
+ }
1240
+ if (provider === undefined || !KNOWN_PROVIDERS.includes(provider)) {
1241
+ throw new CliUsageError(`kiso ${command} <provider> — one of: ${KNOWN_PROVIDERS.join(", ")}`);
1242
+ }
1243
+ if (command === "logout") {
1244
+ const had = deleteCredential(provider);
1245
+ process.stdout.write(had ? `signed out of ${provider}\n` : `nothing stored for ${provider}\n`);
1246
+ break;
1247
+ }
1248
+ if (OAUTH_PROVIDERS.includes(provider)) {
1249
+ // the subscription sign-in (plan step 2): the browser flow with a
1250
+ // paste path; the callback host/port can be moved by env for tests
1251
+ const { chatgptFlow } = await import("./auth/oauth/chatgpt.js");
1252
+ const { createInterface } = await import("node:readline");
1253
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1254
+ const cbPort = process.env.KISO_OAUTH_CALLBACK_PORT !== undefined ? Number(process.env.KISO_OAUTH_CALLBACK_PORT) : undefined;
1255
+ try {
1256
+ const cred = await chatgptFlow.login({
1257
+ notify: (line) => process.stdout.write(`${line}\n`),
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 } : {}),
1262
+ ...(cbPort !== undefined ? { callback: { host: process.env.KISO_OAUTH_CALLBACK_HOST ?? "127.0.0.1", port: cbPort } } : {}),
1263
+ ...(process.env.KISO_OAUTH_TOKEN_URL !== undefined ? { tokenUrl: process.env.KISO_OAUTH_TOKEN_URL } : {}),
1264
+ });
1265
+ setCredential(provider, cred);
1266
+ process.stdout.write(`signed in to ${provider} (${chatgptFlow.label}) — account ${cred.accountId ?? "?"}, expires ${new Date(cred.expires).toISOString()} — stored in ${authPath()}\n`);
1267
+ }
1268
+ finally {
1269
+ rl.close();
1270
+ }
1271
+ break;
1272
+ }
1273
+ const key = (await readSecret(`API key for ${provider}: `)).trim();
1274
+ if (key === "")
1275
+ throw new CliUsageError(`kiso login ${provider}: no key given`);
1276
+ setCredential(provider, { type: "api-key", key, savedAt: Date.now() });
1277
+ process.stdout.write(`signed in to ${provider} with an API key (${maskSecret(key)}) — stored in ${authPath()}\n`);
1278
+ break;
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
+ }
1098
1301
  case "help": {
1099
1302
  // PH-1b (finding PH-F21): the CLI must be able to describe its
1100
1303
  // own configuration — the old help listed five commands and
@@ -1108,6 +1311,11 @@ async function main() {
1108
1311
  " kiso resume pick a session to continue (TTY picker)\n" +
1109
1312
  " kiso resume <id> [prompt] continue a session (one-shot)\n" +
1110
1313
  " kiso sessions list durable sessions\n" +
1314
+ " kiso login <provider> anthropic|openai|deepseek|zai: store an API key (hidden prompt, or stdin when piped);\n" +
1315
+ " chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
1316
+ " kiso logout <provider> remove the stored credential\n" +
1317
+ " kiso auth list stored credentials (keys masked)\n" +
1318
+ " kiso update install the latest release (npm i -g @vincemakes/kiso-code@latest)\n" +
1111
1319
  " kiso help this help\n\n" +
1112
1320
  "flags (any position):\n" +
1113
1321
  " --model <profile|provider/model> pick the model (also /model in-session)\n" +
package/dist/mode.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * switches take effect immediately. The extension NAME rides the runtime's decidedBy
7
7
  * field: an automated denial records decidedBy: "mode:<name>" — the
8
8
  * audit sell. User-level extensions stay on the chain AFTER the mode
9
- * tiers; a user deny always wins (the chain's deny>ask>allow
9
+ * tiers; a user deny always wins (the chain's deny>allow>ask
10
10
  * monotonicity — bypass cannot override an extension deny).
11
11
  */
12
12
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
@@ -29,7 +29,7 @@ export declare function modeFromEnv(): Mode | undefined;
29
29
  * extension name). The CURRENT tier is first: an all-allow chain records
30
30
  * decidedBy = the FIRST SPEAKER, so an auto-allow under the startup mode
31
31
  * names that mode honestly. Order never affects verdicts — the chain is
32
- * deny>ask>allow over the SPEAKING verdicts (abstain = no opinion), so a
32
+ * deny>allow>ask over the SPEAKING verdicts (abstain = no opinion), so a
33
33
  * user extension's deny wins over any mode tier, bypass included (the
34
34
  * monotonicity e2e pins it). */
35
35
  export declare function modeExtensions(): readonly KisoExtension[];
package/dist/mode.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * switches take effect immediately. The extension NAME rides the runtime's decidedBy
7
7
  * field: an automated denial records decidedBy: "mode:<name>" — the
8
8
  * audit sell. User-level extensions stay on the chain AFTER the mode
9
- * tiers; a user deny always wins (the chain's deny>ask>allow
9
+ * tiers; a user deny always wins (the chain's deny>allow>ask
10
10
  * monotonicity — bypass cannot override an extension deny).
11
11
  */
12
12
  export const MODES = ["manual", "default", "accept-edits", "plan", "bypass"];
@@ -78,7 +78,7 @@ function tierVerdict(tier, call) {
78
78
  * extension name). The CURRENT tier is first: an all-allow chain records
79
79
  * decidedBy = the FIRST SPEAKER, so an auto-allow under the startup mode
80
80
  * names that mode honestly. Order never affects verdicts — the chain is
81
- * deny>ask>allow over the SPEAKING verdicts (abstain = no opinion), so a
81
+ * deny>allow>ask over the SPEAKING verdicts (abstain = no opinion), so a
82
82
  * user extension's deny wins over any mode tier, bypass included (the
83
83
  * monotonicity e2e pins it). */
84
84
  export function modeExtensions() {
package/dist/state.d.ts CHANGED
@@ -156,7 +156,10 @@ export declare function setSessionStore(value: {
156
156
  }): void;
157
157
  /** The model name for the status bar — set by makeAgent. */
158
158
  export declare let agentModel: string;
159
- export declare function setAgentModel(value: string): void;
159
+ /** OR-1: the live model's ENDPOINT, set in the same call as the model so
160
+ * the two can never drift: the window lookup keys on (model, endpoint). */
161
+ export declare let agentBaseUrl: string | undefined;
162
+ export declare function setAgentModel(value: string, baseUrl?: string): void;
160
163
  /** merge round B: whether the agent runs on the faux provider (no real key) —
161
164
  * set inside makeAgent, read by main for chat/resume's exhaustion check. */
162
165
  export declare let currentFaux: boolean;
@@ -213,7 +216,9 @@ export declare const CANCELLED: unique symbol;
213
216
  export interface LastBinding {
214
217
  readonly adapter: import("@vincemakes/kiso-core").Adapter;
215
218
  readonly model: string;
216
- readonly provider?: "anthropic" | "openai-compat";
219
+ readonly provider?: "anthropic" | "openai-compat" | "openai-responses";
220
+ /** OR-1: the endpoint inherits with the adapter (the cost path keys on it). */
221
+ readonly baseUrl?: string;
217
222
  readonly scope?: import("@vincemakes/kiso-core").ContinuationScope;
218
223
  readonly reasoning?: import("@vincemakes/kiso-runtime/internal").ReasoningSetting;
219
224
  }
package/dist/state.js CHANGED
@@ -213,8 +213,12 @@ export function setSessionStore(value) {
213
213
  }
214
214
  /** The model name for the status bar — set by makeAgent. */
215
215
  export let agentModel = "faux";
216
- export function setAgentModel(value) {
216
+ /** OR-1: the live model's ENDPOINT, set in the same call as the model so
217
+ * the two can never drift: the window lookup keys on (model, endpoint). */
218
+ export let agentBaseUrl;
219
+ export function setAgentModel(value, baseUrl) {
217
220
  agentModel = value;
221
+ agentBaseUrl = baseUrl;
218
222
  }
219
223
  /** merge round B: whether the agent runs on the faux provider (no real key) —
220
224
  * set inside makeAgent, read by main for chat/resume's exhaustion check. */
@@ -37,6 +37,19 @@ export declare let pendingAsk: (() => void) | null;
37
37
  export declare function askPanel(input: LineInput, view: PanelView, opts?: {
38
38
  safer?: () => Promise<SaferAnswer>;
39
39
  }): Promise<PanelVerdict>;
40
+ /**
41
+ * The dock-less fallback line → a verdict. `y…` allows. `n <reason>` / `no
42
+ * <reason>` DENIES WITH THE REASON — the words become the tool result and
43
+ * the run continues with the model seeing them, the same verdict the panel's
44
+ * "No + words" produces (chat.ts, the deny case). A bare `n`/`no`, or any
45
+ * other line, is the bare denial that aborts the run — unchanged. (LH-1 ruling
46
+ * 2026-09-08: the default refusal across arms is refuse-with-a-reason-and-
47
+ * continue, and the surface a piped or 0-row leg drives is this one.)
48
+ */
49
+ export declare function parseFallbackAnswer(answer: string): {
50
+ action: "allow" | "deny";
51
+ reason: string;
52
+ };
40
53
  /**
41
54
  * KC3.5 — the AskUI bridge: the panel the ask extension asks through.
42
55
  *
package/dist/trust-ui.js CHANGED
@@ -104,12 +104,27 @@ opts) {
104
104
  }
105
105
  settled = true;
106
106
  pendingAsk = null;
107
- const yes = answer.trim().toLowerCase().startsWith("y");
108
- resolve(yes ? { action: "allow", reason: "" } : { action: "deny", reason: "" });
107
+ resolve(parseFallbackAnswer(answer));
109
108
  });
110
109
  }
111
110
  });
112
111
  }
112
+ /**
113
+ * The dock-less fallback line → a verdict. `y…` allows. `n <reason>` / `no
114
+ * <reason>` DENIES WITH THE REASON — the words become the tool result and
115
+ * the run continues with the model seeing them, the same verdict the panel's
116
+ * "No + words" produces (chat.ts, the deny case). A bare `n`/`no`, or any
117
+ * other line, is the bare denial that aborts the run — unchanged. (LH-1 ruling
118
+ * 2026-09-08: the default refusal across arms is refuse-with-a-reason-and-
119
+ * continue, and the surface a piped or 0-row leg drives is this one.)
120
+ */
121
+ export function parseFallbackAnswer(answer) {
122
+ const line = answer.trim();
123
+ if (line.toLowerCase().startsWith("y"))
124
+ return { action: "allow", reason: "" };
125
+ const m = /^(?:n|no)\b[\s:,-]*(.*)$/is.exec(line);
126
+ return { action: "deny", reason: (m?.[1] ?? "").trim() };
127
+ }
113
128
  /**
114
129
  * KC3.5 — the AskUI bridge: the panel the ask extension asks through.
115
130
  *
@@ -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
  }