@vincemakes/kiso-code 0.29.0 → 0.31.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
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * Schema v1:
10
10
  * model?: string — a profile NAME (models.<name>) or "provider/model"
11
- * direct write (provider: "openai-compat" | "anthropic")
12
- * models?: { [name]: { kind: "openai-compat"|"anthropic",
11
+ * direct write (provider: "openai-compat" |
12
+ * "anthropic" | "openai-responses")
13
+ * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
13
14
  * baseUrl?: string, model: string, apiKeyEnv: string } }
14
15
  * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
16
  * contextWindow?: number — tokens
@@ -27,7 +28,11 @@
27
28
  * ignored (forward compatibility).
28
29
  */
29
30
  import type { Mode } from "./mode.js";
30
- export type ProfileKind = "openai-compat" | "anthropic";
31
+ /** OR-1: `openai-responses` is a DIALECT, not a vendor — the same kind
32
+ * drives the first-party Responses API and the ChatGPT subscription
33
+ * backend, and which one it is follows the profile's baseUrl (and so
34
+ * the credential it resolves to). */
35
+ export type ProfileKind = "openai-compat" | "anthropic" | "openai-responses";
31
36
  export interface ModelProfile {
32
37
  readonly kind: ProfileKind;
33
38
  readonly baseUrl?: string;
@@ -59,6 +64,11 @@ export interface KisoConfig {
59
64
  * project file is a LOUD error rather than a silent win. `KISO_THEME`
60
65
  * still outranks it — the environment is the more local answer. */
61
66
  readonly theme?: "dark" | "light";
67
+ /** DT-1a: named acceptance checks a delegated task may reference —
68
+ * user-authored (or trust-gated project) commands, run by the PARENT
69
+ * in the child's worktree. A model never supplies a command; it names
70
+ * one of these. `{ "test": "npm test", "lint": "npm run lint" }`. */
71
+ readonly checks?: Readonly<Record<string, string>>;
62
72
  }
63
73
  /** The resolved, merged config — project wins over user, both validated. */
64
74
  export interface ResolvedConfig {
@@ -77,13 +87,44 @@ export declare function loadUserConfig(): KisoConfig | null;
77
87
  export declare function loadProjectConfig(cwd: string, trusted: boolean): KisoConfig | null;
78
88
  /** Merge: project wins over user (each layer's own keys only). */
79
89
  export declare function mergeConfigs(user: KisoConfig | null, project: KisoConfig | null): KisoConfig;
80
- /** A profile is available when its apiKeyEnv var is set (the key exists). */
90
+ /** What a profile's sign-in actually IS. The two shapes are not
91
+ * interchangeable: an API key is a string an adapter holds, an OAuth
92
+ * sign-in is a token that expires and must be re-resolved per request. */
93
+ export type ProfileAuth = {
94
+ readonly type: "api-key";
95
+ readonly apiKey: string;
96
+ readonly source: "store" | "env" | "none";
97
+ } | {
98
+ readonly type: "oauth";
99
+ readonly providerId: string;
100
+ };
101
+ /** Where a profile's sign-in comes from, or why it has none. The sign-in
102
+ * plan's resolve rule: a stored credential OWNS the provider (an
103
+ * unusable stored one is an error, never a silent fall back to env);
104
+ * with nothing stored, the profile's env var applies; a keyless profile
105
+ * (no apiKeyEnv — PH-1c, finding PH-F19) is an unauthenticated endpoint.
106
+ *
107
+ * OR-1: a stored OAuth credential is USABLE only by an adapter that can
108
+ * drive one, which today is the Responses adapter's ChatGPT target. For
109
+ * every other kind it stays the same loud refusal it has always been —
110
+ * the caller is told which sign-in it has and which one it needs. */
111
+ export declare function authForProfile(name: string, p: ModelProfile): ProfileAuth;
112
+ /** The reason a profile is unavailable, for the surfaces that print it. */
113
+ export declare function unavailableReason(name: string, p: ModelProfile): string;
114
+ /** A profile is available when a sign-in resolves for it — of EITHER
115
+ * shape: an OAuth-only profile is available, and calling the key-only
116
+ * resolver here would have declared it broken. */
81
117
  export declare function profileAvailable(p: ModelProfile): boolean;
82
- /** The resolved runtime model — what the adapter is built from. */
118
+ /** The resolved runtime model — what the adapter is built from. Exactly
119
+ * one of the two sign-in fields is set: an OAuth profile has no key to
120
+ * hand over, and the caller must build the adapter from the token thunk
121
+ * instead (the compiler enforces the branch — `apiKey` is optional). */
83
122
  export interface ResolvedModel {
84
123
  readonly name: string;
85
124
  readonly profile: ModelProfile;
86
- readonly apiKey: string;
125
+ readonly apiKey?: string;
126
+ /** OR-1: the provider whose stored OAuth sign-in this profile uses. */
127
+ readonly oauthProviderId?: string;
87
128
  }
88
129
  /** "provider/model" direct write → a profile. */
89
130
  export declare function directWriteProfile(value: string): ModelProfile | null;
package/dist/config.js CHANGED
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * Schema v1:
10
10
  * model?: string — a profile NAME (models.<name>) or "provider/model"
11
- * direct write (provider: "openai-compat" | "anthropic")
12
- * models?: { [name]: { kind: "openai-compat"|"anthropic",
11
+ * direct write (provider: "openai-compat" |
12
+ * "anthropic" | "openai-responses")
13
+ * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
13
14
  * baseUrl?: string, model: string, apiKeyEnv: string } }
14
15
  * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
16
  * contextWindow?: number — tokens
@@ -29,7 +30,8 @@
29
30
  import { readFileSync } from "node:fs";
30
31
  import { join } from "node:path";
31
32
  import { kisoHome } from "./state.js";
32
- const KINDS = ["openai-compat", "anthropic"];
33
+ import { AuthError, getCredential, providerIdOf } from "./auth/credentials.js";
34
+ const KINDS = ["openai-compat", "anthropic", "openai-responses"];
33
35
  const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass"];
34
36
  export class ConfigError extends Error {
35
37
  }
@@ -63,6 +65,17 @@ export function parseConfig(text, source) {
63
65
  fail("theme", "belongs in the USER config — a terminal is a property of the person at it, not of the project");
64
66
  out.theme = obj.theme;
65
67
  }
68
+ if (obj.checks !== undefined) {
69
+ if (obj.checks === null || typeof obj.checks !== "object" || Array.isArray(obj.checks))
70
+ fail("checks", "expected an object of name → command");
71
+ for (const [name, cmd] of Object.entries(obj.checks)) {
72
+ if (!/^[A-Za-z0-9_-]+$/.test(name))
73
+ fail(`checks.${name}`, "a check name is letters, digits, _ or -");
74
+ if (typeof cmd !== "string" || cmd.trim() === "")
75
+ fail(`checks.${name}`, "expected a non-empty command string");
76
+ }
77
+ out.checks = obj.checks;
78
+ }
66
79
  if (obj.model !== undefined) {
67
80
  if (typeof obj.model !== "string" || obj.model === "")
68
81
  fail("model", "expected a profile name or provider/model string");
@@ -162,13 +175,69 @@ export function mergeConfigs(user, project) {
162
175
  ...(p.autoCompact !== undefined ? { autoCompact: p.autoCompact } : {}),
163
176
  ...(u.projectTrust !== undefined ? { projectTrust: u.projectTrust } : {}),
164
177
  ...(p.projectTrust !== undefined ? { projectTrust: p.projectTrust } : {}),
178
+ // DT-1a: checks merge per name — a (trusted) project's check wins over the user's
179
+ ...(u.checks !== undefined || p.checks !== undefined ? { checks: { ...(u.checks ?? {}), ...(p.checks ?? {}) } } : {}),
165
180
  };
166
181
  }
167
- /** A profile is available when its apiKeyEnv var is set (the key exists). */
182
+ /** Where a profile's sign-in comes from, or why it has none. The sign-in
183
+ * plan's resolve rule: a stored credential OWNS the provider (an
184
+ * unusable stored one is an error, never a silent fall back to env);
185
+ * with nothing stored, the profile's env var applies; a keyless profile
186
+ * (no apiKeyEnv — PH-1c, finding PH-F19) is an unauthenticated endpoint.
187
+ *
188
+ * OR-1: a stored OAuth credential is USABLE only by an adapter that can
189
+ * drive one, which today is the Responses adapter's ChatGPT target. For
190
+ * every other kind it stays the same loud refusal it has always been —
191
+ * the caller is told which sign-in it has and which one it needs. */
192
+ export function authForProfile(name, p) {
193
+ const providerId = providerIdOf(p.kind, p.baseUrl);
194
+ if (providerId !== null) {
195
+ let stored;
196
+ try {
197
+ stored = getCredential(providerId);
198
+ }
199
+ catch (err) {
200
+ if (err instanceof AuthError)
201
+ throw new ConfigError(`model ${name}: ${err.message}`);
202
+ throw err;
203
+ }
204
+ if (stored !== undefined) {
205
+ if (stored.type === "api-key")
206
+ return { type: "api-key", apiKey: stored.key, source: "store" };
207
+ if (p.kind === "openai-responses")
208
+ return { type: "oauth", providerId };
209
+ 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
+ }
212
+ if (p.apiKeyEnv === undefined)
213
+ return { type: "api-key", apiKey: "none", source: "none" };
214
+ const fromEnv = process.env[p.apiKeyEnv];
215
+ if (fromEnv !== undefined)
216
+ return { type: "api-key", apiKey: fromEnv, source: "env" };
217
+ const hint = providerId !== null ? `run \`kiso login ${providerId}\` or set the env var ${p.apiKeyEnv}` : `set the env var ${p.apiKeyEnv}`;
218
+ throw new ConfigError(`model ${name}: unavailable — no credential: ${hint} (configs never store keys, only the env-var name)`);
219
+ }
220
+ /** The reason a profile is unavailable, for the surfaces that print it. */
221
+ export function unavailableReason(name, p) {
222
+ try {
223
+ authForProfile(name, p);
224
+ return "";
225
+ }
226
+ catch (err) {
227
+ return err instanceof Error ? err.message : String(err);
228
+ }
229
+ }
230
+ /** A profile is available when a sign-in resolves for it — of EITHER
231
+ * shape: an OAuth-only profile is available, and calling the key-only
232
+ * resolver here would have declared it broken. */
168
233
  export function profileAvailable(p) {
169
- // PH-1c (finding PH-F19): a keyless profile (no apiKeyEnv) is an
170
- // unauthenticated endpoint — always available.
171
- return p.apiKeyEnv === undefined || process.env[p.apiKeyEnv] !== undefined;
234
+ try {
235
+ authForProfile("?", p);
236
+ return true;
237
+ }
238
+ catch {
239
+ return false;
240
+ }
172
241
  }
173
242
  /** "provider/model" direct write → a profile. */
174
243
  export function directWriteProfile(value) {
@@ -176,11 +245,15 @@ export function directWriteProfile(value) {
176
245
  if (slash <= 0 || slash === value.length - 1)
177
246
  return null;
178
247
  const kind = value.slice(0, slash);
179
- if (kind !== "openai-compat" && kind !== "anthropic")
248
+ if (kind !== "openai-compat" && kind !== "anthropic" && kind !== "openai-responses")
180
249
  return null;
181
250
  return {
182
251
  kind,
183
252
  model: value.slice(slash + 1),
253
+ // OR-1: a direct-write `openai-responses/…` names no baseUrl, so it
254
+ // is the FIRST-PARTY target and its env var is OpenAI's. The
255
+ // ChatGPT backend needs a baseUrl and a stored sign-in, which is a
256
+ // configured profile's job, not a one-line direct write.
184
257
  apiKeyEnv: kind === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY",
185
258
  };
186
259
  }
@@ -243,12 +316,10 @@ export function resolveModel(modelFlag, merged) {
243
316
  return null;
244
317
  }
245
318
  function resolveProfile(name, p) {
246
- if (!profileAvailable(p)) {
247
- throw new ConfigError(`model ${name}: unavailable — the env var ${p.apiKeyEnv} is not set (configs never store keys, only the env-var name)`);
248
- }
249
319
  // PH-1c (finding PH-F19): a keyless profile hands the adapter a
250
320
  // placeholder — the SDKs require SOME string; the endpoint ignores it.
251
- return { name, profile: p, apiKey: p.apiKeyEnv === undefined ? "none" : process.env[p.apiKeyEnv] };
321
+ const auth = authForProfile(name, p);
322
+ return auth.type === "oauth" ? { name, profile: p, oauthProviderId: auth.providerId } : { name, profile: p, apiKey: auth.apiKey };
252
323
  }
253
324
  /** Mode: env (KISO_MODE) beats config.mode; the --mode flag is applied by
254
325
  * main before this runs (flags are the top of the chain). */
package/dist/dispatch.js CHANGED
@@ -5,11 +5,37 @@
5
5
  */
6
6
  import { contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, palette } from "@vincemakes/kiso-tui";
7
7
  import { newSessionId } from "./session-id.js";
8
- import { buildAdapter, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
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
11
  import { agentModel, body, bodyLog, configModels, dock, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
12
- import { directWriteProfile, profileAvailable } from "./config.js";
12
+ import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
13
+ /** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
14
+ * and its default, from the registry, shown wherever the profile is listed
15
+ * — kiso never silently drops a level, and now it shows the ones it takes.
16
+ * A model the registry does not know shows `unknown` (nothing is guessed);
17
+ * it must not read `none`, which is a REAL level since the Responses rows. */
18
+ function effortNote(p) {
19
+ const reasoning = lookupModelMetadata(p.model, p.baseUrl)?.capabilities.reasoning ?? null;
20
+ const effort = reasoning?.effort ?? null;
21
+ if (effort === null)
22
+ return "effort: unknown";
23
+ return `effort: ${effort.levels.map((l) => (l === effort.default ? `[${l}]` : l)).join(" · ")}`;
24
+ }
25
+ /** What signs a profile in, for the listing: the env var's name, `oauth`
26
+ * for a subscription sign-in, or `no key` for an unauthenticated endpoint. */
27
+ function signInNote(p) {
28
+ try {
29
+ const auth = authForProfile("?", p);
30
+ if (auth.type === "oauth")
31
+ return "oauth";
32
+ }
33
+ catch {
34
+ // unavailable — the availability mark says so; the env name still names what would sign it in
35
+ }
36
+ return p.apiKeyEnv ?? "no key";
37
+ }
38
+ import { oauthTokenThunk } from "./auth/token.js";
13
39
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
14
40
  * replay routes through it too — a queued "/last" must never become a
15
41
  * user turn (v2c: the rl lives in main, so lines arrive earlier and the
@@ -295,7 +321,7 @@ export function dispatch(line, ctx) {
295
321
  header: `model — current: ${agentModel}`,
296
322
  options: names.map((name) => {
297
323
  const profile = configModels[name];
298
- const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : [])];
324
+ const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : []), effortNote(profile)];
299
325
  return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · ") };
300
326
  }),
301
327
  // PH-1a (finding PH-F4): the example must be a syntax
@@ -329,7 +355,7 @@ export function dispatch(line, ctx) {
329
355
  else {
330
356
  for (const name of names) {
331
357
  const p = configModels[name];
332
- bodyLog(` ${name} → ${p.kind}/${p.model} · ${p.apiKeyEnv} ${profileAvailable(p) ? "(available)" : "(unavailable)"}`);
358
+ bodyLog(` ${name} → ${p.kind}/${p.model} · ${signInNote(p)} ${profileAvailable(p) ? "(available)" : "(unavailable)"} · ${effortNote(p)}`);
333
359
  }
334
360
  }
335
361
  bodyLog("switch with /model <profile-name|provider/model>");
@@ -347,13 +373,19 @@ export function dispatch(line, ctx) {
347
373
  bodyLog(`no such model profile: ${profName}`);
348
374
  }
349
375
  else if (!profileAvailable(profile)) {
350
- bodyLog(`model ${arg}: unavailable — the env var ${profile.apiKeyEnv} is not set (configs never store keys, only the env-var name)`);
376
+ bodyLog(unavailableReason(arg, profile));
351
377
  }
352
378
  else {
379
+ const auth = authForProfile(profName, profile);
353
380
  const adapter = await buildAdapter(profile.kind, {
354
381
  // PH-1c (PH-F19): a keyless profile = an unauthenticated
355
382
  // endpoint — the placeholder satisfies the SDK's ctor.
356
- apiKey: profile.apiKeyEnv === undefined ? "none" : process.env[profile.apiKeyEnv],
383
+ // OR-1: an OAuth profile passes a token thunk instead
384
+ // — there is no key, and the adapter re-resolves the
385
+ // token per request.
386
+ ...(auth.type === "oauth"
387
+ ? { oauth: oauthTokenThunk(auth.providerId), ...(ctx.session.id !== undefined ? { promptCacheKey: ctx.session.id } : {}) }
388
+ : { apiKey: auth.apiKey }),
357
389
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
358
390
  ...(profile.promptCaching !== undefined ? { promptCaching: profile.promptCaching } : {}),
359
391
  });
@@ -370,7 +402,9 @@ export function dispatch(line, ctx) {
370
402
  let refused = null;
371
403
  if (effortTok !== undefined) {
372
404
  const candidate = { thinking: "default", effort: effortTok };
373
- const resolved = resolveReasoning(profile.model, candidate);
405
+ // OR-1: the endpoint narrows the row — the same model id answers
406
+ // differently at the first-party API and at the subscription backend
407
+ const resolved = resolveReasoning(profile.model, candidate, profile.baseUrl);
374
408
  if (resolved.ok)
375
409
  reasoning = candidate;
376
410
  else
@@ -386,12 +420,15 @@ export function dispatch(line, ctx) {
386
420
  adapter,
387
421
  model: profile.model,
388
422
  provider: profile.kind,
423
+ // OR-1: the endpoint is the fourth passenger — the cost
424
+ // path and the window lookup key on (model, endpoint).
425
+ ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
389
426
  ...(scope !== undefined ? { scope } : {}),
390
427
  ...(reasoning !== undefined ? { reasoning } : {}),
391
428
  };
392
429
  ctx.session.setModelBinding(binding);
393
430
  setLastBinding(binding);
394
- setAgentModel(profile.model);
431
+ setAgentModel(profile.model, profile.baseUrl);
395
432
  setCurrentModelName(arg);
396
433
  body.notice(`model → ${profName} (${profile.model}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
397
434
  }
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
41
41
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
42
42
  import { chat, contextWindowTokens, displayCtxRatio } from "./chat.js";
43
43
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
44
+ import { oauthTokenThunk } from "./auth/token.js";
44
45
  import { checkForUpdate } from "./update-check.js";
45
46
  import { resume } from "./resume.js";
46
47
  import { resumeTail } from "./resume-tail.js";
@@ -590,6 +591,10 @@ async function makeAgent(sessionId, input, modelFlag) {
590
591
  const projectCfg = loadProjectConfig(process.cwd(), project !== null);
591
592
  const merged = mergeConfigs(userCfg, projectCfg);
592
593
  setMergedConfig(merged);
594
+ // DT-1a: what a delegated task may NAME — the configured checks and the
595
+ // model profiles — handed to the (in-process) subagent extension through
596
+ // the environment. A model never supplies a command; it names a check.
597
+ process.env.KISO_DELEGATION_CONFIG_JSON = JSON.stringify({ checks: merged.checks ?? {}, profiles: Object.keys(merged.models ?? {}) });
593
598
  setConfigModels(merged.models ?? {});
594
599
  setConfiguredWindow(resolveContextWindow(merged));
595
600
  const resolved = resolveModel(modelFlag, merged);
@@ -603,7 +608,7 @@ async function makeAgent(sessionId, input, modelFlag) {
603
608
  setCurrentFaux(false);
604
609
  setCurrentModelName(resolved.name);
605
610
  }
606
- setAgentModel(model); // v2b: the status bar shows it
611
+ setAgentModel(model, resolved?.profile.baseUrl); // v2b: the status bar shows it; OR-1: the endpoint rides along
607
612
  // W21: the extensions array is built ONCE per agent and shared with
608
613
  // the runtime by reference — the don't-ask-again writer pushes the
609
614
  // generated extension into it so a first-time rule joins the chain
@@ -643,13 +648,24 @@ async function makeAgent(sessionId, input, modelFlag) {
643
648
  // field survives for the callers that want a bound (subagents, the
644
649
  // SDK, `kiso run`); the interactive front door does not set one.
645
650
  // Modes: the five tiers join at the CHAIN HEAD, before the user/
646
- // project extensions (the deny>ask>allow composition keeps a user
651
+ // project extensions (the deny>allow>ask composition keeps a user
647
652
  // deny winning over any mode tier — bypass included).
648
653
  extensions,
649
654
  ...(resolved !== null
650
655
  ? {
651
656
  provider: resolved.profile.kind,
652
- apiKey: resolved.apiKey,
657
+ // OR-1: exactly one sign-in shape reaches the adapter. An
658
+ // OAuth profile has no key to pass — it passes the thunk
659
+ // the adapter re-resolves per request instead.
660
+ ...(resolved.oauthProviderId !== undefined
661
+ ? { oauth: oauthTokenThunk(resolved.oauthProviderId) }
662
+ : { apiKey: resolved.apiKey ?? "none" }),
663
+ // OR-1: the ChatGPT backend's cache lane is the SESSION —
664
+ // one conversation's requests share a key, different
665
+ // conversations never do. The one entry point that hands
666
+ // no session id is `kiso sessions`, a read-only listing
667
+ // that streams nothing, so its absence costs no cache.
668
+ ...(sessionId !== undefined ? { promptCacheKey: sessionId } : {}),
653
669
  ...(resolved.profile.baseUrl !== undefined ? { baseUrl: resolved.profile.baseUrl } : {}),
654
670
  ...(resolved.profile.promptCaching !== undefined ? { promptCaching: resolved.profile.promptCaching } : {}),
655
671
  }
@@ -785,7 +801,7 @@ async function chatLoop(agent, firstId, input, autoCompact) {
785
801
  // XP-1 §2.1: the switched-to session's OWN truth repaints the row —
786
802
  // the global display state never outlives the session it described
787
803
  // (pre-XP the row kept the previous session's /model selection).
788
- setAgentModel(session.model);
804
+ setAgentModel(session.model, session.baseUrl);
789
805
  setCurrentModelName(session.model);
790
806
  paintBootStatus(session);
791
807
  const nav = {
@@ -799,6 +815,51 @@ async function chatLoop(agent, firstId, input, autoCompact) {
799
815
  id = end.id;
800
816
  }
801
817
  }
818
+ /** A secret from the terminal: hidden input on a TTY, the whole of stdin
819
+ * otherwise (a pipe, a heredoc) — never an argument, which shell history
820
+ * would keep. */
821
+ async function readSecret(prompt) {
822
+ if (process.stdin.isTTY !== true) {
823
+ return await new Promise((resolve) => {
824
+ let buf = "";
825
+ process.stdin.setEncoding("utf8");
826
+ process.stdin.on("data", (d) => {
827
+ buf += d;
828
+ });
829
+ process.stdin.on("end", () => resolve(buf.split("\n")[0] ?? ""));
830
+ process.stdin.resume();
831
+ });
832
+ }
833
+ process.stdout.write(prompt);
834
+ return await new Promise((resolve) => {
835
+ let buf = "";
836
+ process.stdin.setRawMode(true);
837
+ process.stdin.resume();
838
+ process.stdin.setEncoding("utf8");
839
+ const onData = (chunk) => {
840
+ for (const ch of chunk) {
841
+ if (ch === "\r" || ch === "\n") {
842
+ process.stdin.setRawMode(false);
843
+ process.stdin.pause();
844
+ process.stdin.off("data", onData);
845
+ process.stdout.write("\n");
846
+ resolve(buf);
847
+ return;
848
+ }
849
+ if (ch === "\u0003") {
850
+ process.stdin.setRawMode(false);
851
+ process.stdout.write("\n");
852
+ process.exit(130);
853
+ }
854
+ if (ch === "\u007f" || ch === "\b")
855
+ buf = buf.slice(0, -1);
856
+ else
857
+ buf += ch;
858
+ }
859
+ };
860
+ process.stdin.on("data", onData);
861
+ });
862
+ }
802
863
  async function main() {
803
864
  // E group (the graceful-exit gate ③, R-G 0.1.48): a terminal closing
804
865
  // turns the in-flight stdout/stderr writes into EIO, and node's
@@ -1091,6 +1152,67 @@ async function main() {
1091
1152
  }
1092
1153
  break;
1093
1154
  }
1155
+ case "login":
1156
+ case "logout":
1157
+ case "auth": {
1158
+ // the sign-in plan (2026-09-08), step 1: the credential store.
1159
+ // `login <provider>` reads the key from a hidden prompt on a TTY
1160
+ // or from stdin otherwise (never from an argument — shell history);
1161
+ // `logout <provider>` deletes; `auth` lists, keys masked.
1162
+ const { KNOWN_PROVIDERS, OAUTH_PROVIDERS, authPath, deleteCredential, maskSecret, readAuthFile, setCredential } = await import("./auth/credentials.js");
1163
+ const provider = arg;
1164
+ if (command === "auth") {
1165
+ const file = readAuthFile();
1166
+ const rows = Object.entries(file.credentials);
1167
+ const lines = [`credentials: ${authPath()}${rows.length === 0 ? " (none stored)" : ""}`];
1168
+ for (const [id, cred] of rows) {
1169
+ if (cred.type === "api-key")
1170
+ lines.push(` ${id.padEnd(10)} api-key ${maskSecret(cred.key)} saved ${new Date(cred.savedAt).toISOString().slice(0, 10)}`);
1171
+ else
1172
+ lines.push(` ${id.padEnd(10)} oauth ${cred.accountId ?? ""} expires ${new Date(cred.expires).toISOString()}${cred.expires < Date.now() ? " (expired)" : ""}`);
1173
+ }
1174
+ const envs = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].filter((k) => process.env[k] !== undefined);
1175
+ lines.push(`env vars set: ${envs.length ? envs.join(", ") : "none"} (a stored credential owns its provider; env applies only when nothing is stored)`);
1176
+ process.stdout.write(`${lines.join("\n")}\n`);
1177
+ break;
1178
+ }
1179
+ if (provider === undefined || !KNOWN_PROVIDERS.includes(provider)) {
1180
+ throw new CliUsageError(`kiso ${command} <provider> — one of: ${KNOWN_PROVIDERS.join(", ")}`);
1181
+ }
1182
+ if (command === "logout") {
1183
+ const had = deleteCredential(provider);
1184
+ process.stdout.write(had ? `signed out of ${provider}\n` : `nothing stored for ${provider}\n`);
1185
+ break;
1186
+ }
1187
+ if (OAUTH_PROVIDERS.includes(provider)) {
1188
+ // the subscription sign-in (plan step 2): the browser flow with a
1189
+ // paste path; the callback host/port can be moved by env for tests
1190
+ const { chatgptFlow } = await import("./auth/oauth/chatgpt.js");
1191
+ const { createInterface } = await import("node:readline");
1192
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1193
+ const cbPort = process.env.KISO_OAUTH_CALLBACK_PORT !== undefined ? Number(process.env.KISO_OAUTH_CALLBACK_PORT) : undefined;
1194
+ try {
1195
+ const cred = await chatgptFlow.login({
1196
+ notify: (line) => process.stdout.write(`${line}\n`),
1197
+ prompt: (q) => new Promise((resolve) => rl.question(q, resolve)),
1198
+ ...(cbPort !== undefined ? { callback: { host: process.env.KISO_OAUTH_CALLBACK_HOST ?? "127.0.0.1", port: cbPort } } : {}),
1199
+ ...(process.env.KISO_OAUTH_TOKEN_URL !== undefined ? { tokenUrl: process.env.KISO_OAUTH_TOKEN_URL } : {}),
1200
+ });
1201
+ setCredential(provider, cred);
1202
+ process.stdout.write(`signed in to ${provider} (${chatgptFlow.label}) — account ${cred.accountId ?? "?"}, expires ${new Date(cred.expires).toISOString()} — stored in ${authPath()}\n`);
1203
+ }
1204
+ finally {
1205
+ rl.close();
1206
+ }
1207
+ break;
1208
+ }
1209
+ const key = (await readSecret(`API key for ${provider}: `)).trim();
1210
+ if (key === "")
1211
+ throw new CliUsageError(`kiso login ${provider}: no key given`);
1212
+ setCredential(provider, { type: "api-key", key, savedAt: Date.now() });
1213
+ process.stdout.write(`signed in to ${provider} with an API key (${maskSecret(key)}) — stored in ${authPath()}\n`);
1214
+ break;
1215
+ }
1094
1216
  case "help": {
1095
1217
  // PH-1b (finding PH-F21): the CLI must be able to describe its
1096
1218
  // own configuration — the old help listed five commands and
@@ -1104,6 +1226,10 @@ async function main() {
1104
1226
  " kiso resume pick a session to continue (TTY picker)\n" +
1105
1227
  " kiso resume <id> [prompt] continue a session (one-shot)\n" +
1106
1228
  " kiso sessions list durable sessions\n" +
1229
+ " kiso login <provider> anthropic|openai|deepseek|zai: store an API key (hidden prompt, or stdin when piped);\n" +
1230
+ " chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
1231
+ " kiso logout <provider> remove the stored credential\n" +
1232
+ " kiso auth list stored credentials (keys masked)\n" +
1107
1233
  " kiso help this help\n\n" +
1108
1234
  "flags (any position):\n" +
1109
1235
  " --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
  *