@vincemakes/kiso-code 0.33.0 → 0.36.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.
@@ -0,0 +1,22 @@
1
+ import type { ModelProfile } from "../config.js";
2
+ /** The credential shape both call sites can produce: `authForProfile`'s
3
+ * return value, and the startup path's already-narrowed `resolved`. */
4
+ export type AdapterAuth = {
5
+ readonly type: "oauth";
6
+ readonly providerId: string;
7
+ } | {
8
+ readonly type: "api-key";
9
+ readonly apiKey: string;
10
+ };
11
+ /** Exactly the runtime `buildAdapter` option set. */
12
+ export interface AdapterOptions {
13
+ readonly apiKey?: string;
14
+ readonly baseUrl?: string;
15
+ readonly promptCaching?: boolean;
16
+ readonly oauth?: () => Promise<{
17
+ readonly access: string;
18
+ readonly accountId: string;
19
+ }>;
20
+ readonly promptCacheKey?: string;
21
+ }
22
+ export declare function adapterOptionsFor(profile: ModelProfile, auth: AdapterAuth, sessionId?: string): AdapterOptions;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Astra F1 (P0) — THE ONE PLACE THE ADAPTER'S WIRE CONFIG IS BUILT.
3
+ *
4
+ * The P0's mechanism is a single sentence: the CLI hands the adapter an
5
+ * EXPLICIT endpoint, so the provider SDKs never read `ANTHROPIC_BASE_URL` /
6
+ * `OPENAI_BASE_URL` for themselves while `authForProfile` has already
7
+ * chosen the STORED VENDOR KEY for the vendor's own origin.
8
+ *
9
+ * That sentence had no home. It was a spread written out twice — at startup
10
+ * in index.ts and on the `/model` switch in dispatch.ts — which is F1's own
11
+ * defect class: one rule, two copies, and the copies drift. The first fix
12
+ * for this finding edited both copies. This gives the rule somewhere to
13
+ * live, and a test somewhere to point.
14
+ *
15
+ * The extraction found the two sites had ALREADY drifted:
16
+ *
17
+ * - `promptCacheKey` was passed at startup whenever the session had an
18
+ * id, but on `/model` only for an OAuth profile. It reaches the wire in
19
+ * the openai-responses adapter alone (runtime resolveAdapter), where it
20
+ * becomes `prompt_cache_key`. So a first-party Responses profile got a
21
+ * cache key when you started with it and none when you switched to it —
22
+ * the same session, two cache lanes, decided by how you arrived. The
23
+ * startup rule is the one kept: the cache lane is the SESSION (OR-1).
24
+ * - `streamIdleMs` is set at startup and not on `/model`. It is NOT an
25
+ * adapter option — it is an agent-definition field the runtime reads —
26
+ * so it is out of this function's scope and stays where it is, noted
27
+ * here so the next reader does not think it was missed.
28
+ */
29
+ import { effectiveBaseUrl } from "./credentials.js";
30
+ import { oauthTokenThunk } from "./token.js";
31
+ export function adapterOptionsFor(profile, auth, sessionId) {
32
+ // OR-1: exactly one sign-in shape reaches the adapter. An OAuth profile
33
+ // has no key to pass — it passes the thunk the adapter re-resolves per
34
+ // request instead. PH-1c (PH-F19): a keyless profile is an
35
+ // unauthenticated endpoint and its placeholder satisfies the SDK's ctor.
36
+ const credential = auth.type === "oauth" ? { oauth: oauthTokenThunk(auth.providerId) } : { apiKey: auth.apiKey };
37
+ // Astra F1 (P0): ALWAYS EXPLICIT. Passing nothing let the SDK read its
38
+ // own environment variable, and a profile with no baseUrl then sent the
39
+ // stored vendor key wherever that variable pointed.
40
+ const url = effectiveBaseUrl(profile.kind, profile.baseUrl);
41
+ return {
42
+ ...credential,
43
+ ...(url !== undefined ? { baseUrl: url } : {}),
44
+ // OR-1: the ChatGPT backend's cache lane is the SESSION — one
45
+ // conversation's requests share a key, different conversations never
46
+ // do. The one entry point with no session id is `kiso sessions`, a
47
+ // read-only listing that streams nothing, so its absence costs no
48
+ // cache.
49
+ ...(sessionId !== undefined ? { promptCacheKey: sessionId } : {}),
50
+ ...(profile.promptCaching !== undefined ? { promptCaching: profile.promptCaching } : {}),
51
+ };
52
+ }
@@ -23,6 +23,29 @@ export declare class AuthError extends Error {
23
23
  export declare const KNOWN_PROVIDERS: readonly string[];
24
24
  /** The providers whose sign-in is an OAuth flow rather than a key. */
25
25
  export declare const OAUTH_PROVIDERS: readonly string[];
26
+ /**
27
+ * Astra F1 (P0) — THE ONE RESOLVED EFFECTIVE ENDPOINT.
28
+ *
29
+ * `providerIdOf` read an ABSENT `baseUrl` as the vendor's own endpoint and
30
+ * returned the vendor id, so `authForProfile` handed back the STORED vendor
31
+ * key. Correct in isolation. But the adapter factories passed `baseURL` to the
32
+ * SDK only when the profile NAMED one, and with none named the Anthropic and
33
+ * OpenAI SDKs read `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` from the
34
+ * environment themselves — so the saved official key went to whatever the
35
+ * environment named.
36
+ *
37
+ * Two sides disagreed about what "no baseUrl" meant. THE FIX IS TO RESOLVE IT
38
+ * ONCE, here, and give both sides the same answer: the credential resolver
39
+ * decides which key may go, the adapter is handed an EXPLICIT url so the SDK
40
+ * never consults its environment.
41
+ *
42
+ * 0.32.2's R1 bound the explicit-URL path. This is the absent-URL path, which
43
+ * is the common configuration.
44
+ */
45
+ export declare const VENDOR_DEFAULT: Readonly<Record<string, string>>;
46
+ /** The endpoint a profile ACTUALLY talks to: its own, or the vendor's
47
+ * default for its kind. Never the environment's — that is the defect. */
48
+ export declare function effectiveBaseUrl(kind: string, baseUrl?: string): string | undefined;
26
49
  /** The provider identity a profile's credential is stored under, or null
27
50
  * for an origin nobody recognizes (custom: env var only). */
28
51
  export declare function providerIdOf(kind: string, baseUrl?: string): string | null;
@@ -50,6 +50,35 @@ const CHATGPT_ORIGIN = "https://chatgpt.com";
50
50
  * the dialect it speaks. */
51
51
  const ANTHROPIC_ORIGIN = "https://api.anthropic.com";
52
52
  const OPENAI_ORIGIN = "https://api.openai.com";
53
+ /**
54
+ * Astra F1 (P0) — THE ONE RESOLVED EFFECTIVE ENDPOINT.
55
+ *
56
+ * `providerIdOf` read an ABSENT `baseUrl` as the vendor's own endpoint and
57
+ * returned the vendor id, so `authForProfile` handed back the STORED vendor
58
+ * key. Correct in isolation. But the adapter factories passed `baseURL` to the
59
+ * SDK only when the profile NAMED one, and with none named the Anthropic and
60
+ * OpenAI SDKs read `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` from the
61
+ * environment themselves — so the saved official key went to whatever the
62
+ * environment named.
63
+ *
64
+ * Two sides disagreed about what "no baseUrl" meant. THE FIX IS TO RESOLVE IT
65
+ * ONCE, here, and give both sides the same answer: the credential resolver
66
+ * decides which key may go, the adapter is handed an EXPLICIT url so the SDK
67
+ * never consults its environment.
68
+ *
69
+ * 0.32.2's R1 bound the explicit-URL path. This is the absent-URL path, which
70
+ * is the common configuration.
71
+ */
72
+ export const VENDOR_DEFAULT = {
73
+ anthropic: ANTHROPIC_ORIGIN,
74
+ "openai-compat": `${OPENAI_ORIGIN}/v1`,
75
+ "openai-responses": `${OPENAI_ORIGIN}/v1`,
76
+ };
77
+ /** The endpoint a profile ACTUALLY talks to: its own, or the vendor's
78
+ * default for its kind. Never the environment's — that is the defect. */
79
+ export function effectiveBaseUrl(kind, baseUrl) {
80
+ return baseUrl ?? VENDOR_DEFAULT[kind];
81
+ }
53
82
  /** The provider identity a profile's credential is stored under, or null
54
83
  * for an origin nobody recognizes (custom: env var only). */
55
84
  export function providerIdOf(kind, baseUrl) {
@@ -74,23 +103,24 @@ export function providerIdOf(kind, baseUrl) {
74
103
  // The compat kind was already right — an unrecognized origin resolves
75
104
  // to null and the env var pays — so this makes the other two agree with
76
105
  // the one that had the rule.
77
- const origin = originOf(baseUrl);
78
- if (kind === "anthropic") {
79
- if (baseUrl === undefined)
80
- return "anthropic"; // the vendor's own default endpoint
106
+ //
107
+ // Astra F1: THE SAME RESOLVER THE ADAPTER USES. An absent baseUrl used to
108
+ // be a special case here — three `if (baseUrl === undefined) return "<the
109
+ // vendor>"` branches — and a DIFFERENT special case at the adapter, where
110
+ // it meant "let the SDK read the environment". Two copies of one rule is
111
+ // how the two sides came to disagree. Resolved once, this function is left
112
+ // with the single question it exists to ask: does that origin belong to
113
+ // the vendor?
114
+ const origin = originOf(effectiveBaseUrl(kind, baseUrl));
115
+ if (kind === "anthropic")
81
116
  return origin === ANTHROPIC_ORIGIN ? "anthropic" : null;
82
- }
83
117
  if (kind === "openai-responses") {
84
- if (baseUrl === undefined)
85
- return "openai"; // the vendor's own default endpoint
86
118
  if (origin === CHATGPT_ORIGIN)
87
119
  return "chatgpt";
88
120
  return origin === OPENAI_ORIGIN ? "openai" : null;
89
121
  }
90
122
  if (kind !== "openai-compat")
91
123
  return null;
92
- if (baseUrl === undefined)
93
- return "openai";
94
124
  return origin === null ? null : (ORIGIN_TO_PROVIDER[origin] ?? null);
95
125
  }
96
126
  function originOf(baseUrl) {
package/dist/builtin.d.ts CHANGED
@@ -5,4 +5,9 @@ export declare function builtInLayer(user: readonly KisoExtension[], project: re
5
5
  * bridge exists (an interactive TTY). No bridge, no fourth built-in:
6
6
  * a headless session never pays the rent for a question nobody could
7
7
  * answer, and its tool table cannot mention ask_user. */
8
- ask?: AskUI): Promise<readonly KisoExtension[]>;
8
+ ask?: AskUI,
9
+ /** Astra F7: the env-var NAMES the configured profiles authenticate with.
10
+ * The mcp extension spawns its stdio children while it is being
11
+ * constructed, so the names have to arrive here — after construction is
12
+ * too late. Only the config knows them; nothing else can. */
13
+ secretEnvNames?: readonly string[]): Promise<readonly KisoExtension[]>;
package/dist/builtin.js CHANGED
@@ -30,8 +30,13 @@ export async function builtInLayer(user, project,
30
30
  * bridge exists (an interactive TTY). No bridge, no fourth built-in:
31
31
  * a headless session never pays the rent for a question nobody could
32
32
  * answer, and its tool table cannot mention ask_user. */
33
- ask) {
34
- const all = await Promise.all([createMcp(), createSkills(), createSubagent(), ...(ask === undefined ? [] : [createAsk(ask)])]);
33
+ ask,
34
+ /** Astra F7: the env-var NAMES the configured profiles authenticate with.
35
+ * The mcp extension spawns its stdio children while it is being
36
+ * constructed, so the names have to arrive here — after construction is
37
+ * too late. Only the config knows them; nothing else can. */
38
+ secretEnvNames = []) {
39
+ const all = await Promise.all([createMcp({ secretEnvNames }), createSkills(), createSubagent(), ...(ask === undefined ? [] : [createAsk(ask)])]);
35
40
  const shadowed = all.filter((b) => user.some((u) => u.name === b.name));
36
41
  for (const s of shadowed) {
37
42
  console.error(`[extensions] user extension "${s.name}" shadows the built-in — the built-in is not loaded`);
package/dist/chat.js CHANGED
@@ -148,8 +148,18 @@ endpoint) {
148
148
  // backend's own words and keeps its 0 — the display's rule, not the
149
149
  // record's.
150
150
  const unobservable = model !== undefined && lookupModelMetadata(model, endpoint)?.capabilities.promptCaching === "unobservable";
151
+ // DF-0322-F2: the DISPLAY is handed the provider's own word, not the
152
+ // canonical one. `canonicalizeUsage` sets `cacheRead ?? 0` because
153
+ // accounting has to sum numbers — correct there, and pinned that way by
154
+ // the gate — but it erases the difference between "the backend answered
155
+ // zero" and "the backend said nothing about caching". Handing the row the
156
+ // canonical zero made it claim a measurement that never happened, which
157
+ // is the family OR-10 and DF-0311-F1 belong to. OR-10's own comment drew
158
+ // this line first: the ledger keeps the backend's 0, the display's rule
159
+ // is not the record's.
160
+ const reportedCache = ev.cacheRead === null ? null : c.cacheRead;
151
161
  return {
152
- usage: { in: c.input, out: c.output, cache: unobservable ? null : c.cacheRead, known: ev.known },
162
+ usage: { in: c.input, out: c.output, cache: unobservable ? null : reportedCache, known: ev.known },
153
163
  total,
154
164
  missed: unobservable ? null : missed,
155
165
  costUsd: c.costUsd,
package/dist/config.js CHANGED
@@ -296,31 +296,53 @@ export function resolveModel(modelFlag, merged) {
296
296
  return resolveProfile(modelFlag, p);
297
297
  }
298
298
  // env beats config: a key in the environment names the provider.
299
+ //
300
+ // Astra F2: the credential goes through authForProfile like every other
301
+ // route. This returned process.env.OPENAI_API_KEY directly, so the two
302
+ // ways into the same adapter disagreed about which key a profile uses.
303
+ // The safety property held by accident — the env key is not the stored
304
+ // one — and an accident is not a rule. Now: the stored key at the
305
+ // vendor's origin, the env key at a custom one, and a stored vendor key
306
+ // is never forwarded to a custom environment URL.
307
+ //
308
+ // The env var is folded into the profile EXPLICITLY, so the resolved
309
+ // endpoint is the profile's and the SDK never reads it again (F1).
299
310
  if (process.env.OPENAI_API_KEY !== undefined) {
300
- return {
301
- name: process.env.OPENAI_MODEL ?? "gpt-4o",
302
- profile: {
303
- kind: "openai-compat",
304
- model: process.env.OPENAI_MODEL ?? "gpt-4o",
305
- ...(process.env.OPENAI_BASE_URL !== undefined ? { baseUrl: process.env.OPENAI_BASE_URL } : {}),
306
- apiKeyEnv: "OPENAI_API_KEY",
307
- },
308
- apiKey: process.env.OPENAI_API_KEY,
311
+ const profile = {
312
+ kind: "openai-compat",
313
+ model: process.env.OPENAI_MODEL ?? "gpt-4o",
314
+ ...(process.env.OPENAI_BASE_URL !== undefined ? { baseUrl: process.env.OPENAI_BASE_URL } : {}),
315
+ apiKeyEnv: "OPENAI_API_KEY",
309
316
  };
317
+ const auth = authForProfile(process.env.OPENAI_MODEL ?? "gpt-4o", profile);
318
+ // The same total shape the config route returns (resolveProfile).
319
+ // The `oauth` arm is unreachable from HERE — authForProfile returns
320
+ // oauth only for an openai-responses profile and this route builds
321
+ // an openai-compat one — but writing the union out beats a fallback
322
+ // to the raw env var, which is the very disagreement F2 removes.
323
+ return auth.type === "oauth"
324
+ ? { name: process.env.OPENAI_MODEL ?? "gpt-4o", profile, oauthProviderId: auth.providerId }
325
+ : { name: process.env.OPENAI_MODEL ?? "gpt-4o", profile, apiKey: auth.apiKey };
310
326
  }
311
327
  if (process.env.ANTHROPIC_API_KEY !== undefined) {
312
- return {
313
- name: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5",
314
- profile: {
315
- kind: "anthropic",
316
- model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5",
317
- apiKeyEnv: "ANTHROPIC_API_KEY",
318
- // PH-1c (finding PH-F19): symmetric with OPENAI_BASE_URL —
319
- // proxies and compat gateways serve the anthropic dialect too.
320
- ...(process.env.ANTHROPIC_BASE_URL !== undefined ? { baseUrl: process.env.ANTHROPIC_BASE_URL } : {}),
321
- },
322
- apiKey: process.env.ANTHROPIC_API_KEY,
328
+ // Astra F2, the sibling. Same rule, same reason.
329
+ const profile = {
330
+ kind: "anthropic",
331
+ model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5",
332
+ apiKeyEnv: "ANTHROPIC_API_KEY",
333
+ // PH-1c (finding PH-F19): symmetric with OPENAI_BASE_URL —
334
+ // proxies and compat gateways serve the anthropic dialect too.
335
+ ...(process.env.ANTHROPIC_BASE_URL !== undefined ? { baseUrl: process.env.ANTHROPIC_BASE_URL } : {}),
323
336
  };
337
+ const auth = authForProfile(process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5", profile);
338
+ // The same total shape the config route returns (resolveProfile).
339
+ // The `oauth` arm is unreachable from HERE — authForProfile returns
340
+ // oauth only for an openai-responses profile and this route builds
341
+ // an anthropic one — but writing the union out beats a fallback
342
+ // to the raw env var, which is the very disagreement F2 removes.
343
+ return auth.type === "oauth"
344
+ ? { name: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5", profile, oauthProviderId: auth.providerId }
345
+ : { name: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5", profile, apiKey: auth.apiKey };
324
346
  }
325
347
  // config (project wins over user) names a model; default is faux.
326
348
  const configured = merged.model;
package/dist/dispatch.js CHANGED
@@ -9,6 +9,7 @@ import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveRea
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, codingToolOptions, kisoHome, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setModelChoice, setLastBinding } from "./state.js";
12
+ import { adapterOptionsFor } from "./auth/adapter-options.js";
12
13
  import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
13
14
  import { shellTool } from "@vincemakes/kiso-tools-node";
14
15
  import { join } from "node:path";
@@ -74,7 +75,6 @@ function signInNote(p) {
74
75
  }
75
76
  return p.apiKeyEnv ?? "no key";
76
77
  }
77
- import { oauthTokenThunk } from "./auth/token.js";
78
78
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
79
79
  * replay routes through it too — a queued "/last" must never become a
80
80
  * user turn (v2c: the rl lives in main, so lines arrive earlier and the
@@ -601,18 +601,11 @@ export function dispatch(line, ctx) {
601
601
  }
602
602
  else {
603
603
  const auth = authForProfile(profName, profile);
604
- const adapter = await buildAdapter(profile.kind, {
605
- // PH-1c (PH-F19): a keyless profile = an unauthenticated
606
- // endpoint — the placeholder satisfies the SDK's ctor.
607
- // OR-1: an OAuth profile passes a token thunk instead
608
- // — there is no key, and the adapter re-resolves the
609
- // token per request.
610
- ...(auth.type === "oauth"
611
- ? { oauth: oauthTokenThunk(auth.providerId), ...(ctx.session.id !== undefined ? { promptCacheKey: ctx.session.id } : {}) }
612
- : { apiKey: auth.apiKey }),
613
- ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
614
- ...(profile.promptCaching !== undefined ? { promptCaching: profile.promptCaching } : {}),
615
- });
604
+ // Astra F1 (P0): the wire config is built in ONE place
605
+ // (auth/adapter-options.ts) — this site and the startup
606
+ // site were the same spread written twice, which is the
607
+ // finding's own defect class.
608
+ const adapter = await buildAdapter(profile.kind, adapterOptionsFor(profile, auth, ctx.session.id));
616
609
  // PH-1a (finding PH-F8, P0): the switch is ATOMIC —
617
610
  // adapter, model id, and provider route move together.
618
611
  // setAdapter alone left the session's frozen config
package/dist/index.d.ts CHANGED
@@ -31,7 +31,7 @@ export { applyProjectMerges } from "./trust-ui.js";
31
31
  */
32
32
  /** The built-in prompt. Exported for scripts/request-surface.mjs — the
33
33
  * model-side token-rent counter measures the REAL bytes, never a copy. */
34
- export declare const SYSTEM_PROMPT = "You are kiso, a coding agent. You work in a workspace\ndirectory and change code with tools. Be concise: answer in a few lines\nunless the task genuinely needs more. Never claim a file was changed\nunless a tool confirmed it.\n\nTool discipline:\n- READ BEFORE YOU EDIT. For any file you are about to change, read it\n first \u2014 never guess its content.\n- Use edit_file for targeted changes and write_file for full rewrites.\n Prefer many small edits over one large write.\n- shell is for commands: builds, tests, git, grep. Be careful \u2014 shell has\n side effects and may take time. Run one command at a time and inspect\n the output before continuing.\n- Batch independent tool calls into one reply \u2014 they run in parallel.\n- search_text and list_dir are cheap \u2014 locate first, then read ranges\n with read_file offset/limit; never read a whole large file in one call.\n- Do not re-read a file you already read unchanged \u2014 rely on the earlier\n result.\n- When a tool fails, read the error and adjust; do not repeat the same\n call blindly.\n\nWorkflow: understand the request, find the relevant code, make the\nsmallest change that works, then verify with a command (tests/build).\nReport what you did in one or two lines per change.";
34
+ export declare const SYSTEM_PROMPT = "You are kiso, a coding agent. You work in a workspace\ndirectory and change code with tools. Be concise: answer in a few lines\nunless the task genuinely needs more. Never claim a file was changed\nunless a tool confirmed it.\n\nWhat you can reach:\n- The workspace: read_file, list_dir, search_text, write_file, edit_file.\n- This machine and the network: shell \u2014 builds, tests, git, package\n managers, curl for HTTP, system queries. A request one command can\n answer is answered by running it.\n- The human: ask_user, for a decision that is theirs.\n\nTool discipline:\n- READ BEFORE YOU EDIT. For any file you are about to change, read it\n first \u2014 never guess its content.\n- Use edit_file for targeted changes and write_file for full rewrites.\n Prefer many small edits over one large write.\n- shell is for commands: builds, tests, git, grep. Be careful \u2014 shell has\n side effects and may take time. Run one command at a time and inspect\n the output before continuing.\n- Batch independent tool calls into one reply \u2014 they run in parallel.\n- search_text and list_dir are cheap \u2014 locate first, then read ranges\n with read_file offset/limit; never read a whole large file in one call.\n- Do not re-read a file you already read unchanged \u2014 rely on the earlier\n result.\n- When a tool fails, read the error and adjust; do not repeat the same\n call blindly.\n\nAn authorization covers what it NAMES: when the scope is not named \u2014\nwhich files, which branches, whether to push \u2014 ask before acting.\nDelivering, sharing or handing over changes means committing locally and\nstopping; pushing, publishing or sending happens only when the human\nnames it.\n\nWorkflow: understand the request, find the relevant code, make the\nsmallest change that works, then verify with a command (tests/build).\nReport what you did in one or two lines per change.";
35
35
  /** Hard cap for injected instructions — truncate and say so. */
36
36
  /**
37
37
  * A area: read the FIRST present instruction file (AGENTS.md preferred) and
package/dist/index.js CHANGED
@@ -37,13 +37,13 @@ import { createCodingTools } from "@vincemakes/kiso-tools-node";
37
37
  import { MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
38
38
  import { breakerExtension } from "./breaker.js";
39
39
  import { builtInLayer } from "./builtin.js";
40
- import { agentModel, atFiles, body, bodyLog, codingToolOptions, kisoHome, builtInExtensions, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, modelChoice, projectExtensions, configModels, configuredWindow, agentBaseUrl, currentModelName, currentAgentExtensions, sessionStoreRef, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, setModelChoice, setSessionStore, userExtensions, VERSION, lastBinding, acceptDrift, setAcceptDrift } from "./state.js";
40
+ import { agentModel, atFiles, body, bodyLog, codingToolOptions, kisoHome, builtInExtensions, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, modelChoice, projectExtensions, configModels, configuredWindow, agentBaseUrl, currentModelName, currentAgentExtensions, sessionStoreRef, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, setModelChoice, setSessionStore, secretEnvNamesOf, userExtensions, VERSION, lastBinding, acceptDrift, setAcceptDrift } from "./state.js";
41
41
  import { askUi, resolveProjectTrust } from "./trust-ui.js";
42
42
  import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
43
43
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
44
44
  import { chat, contextWindowTokens, displayCtxRatio, statusModelLabel } from "./chat.js";
45
+ import { adapterOptionsFor } from "./auth/adapter-options.js";
45
46
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
46
- import { oauthTokenThunk } from "./auth/token.js";
47
47
  import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
48
48
  import { tmuxMouseHint } from "./tmux-hint.js";
49
49
  import { resume } from "./resume.js";
@@ -96,6 +96,47 @@ function streamIdleFromEnv() {
96
96
  * OR-9 adds `update`: npm inherits the terminal for the install's own
97
97
  * progress and prompts, so no editor may hold stdin while it runs. */
98
98
  const CREDENTIAL_COMMANDS = new Set(["login", "logout", "auth", "update"]);
99
+ /**
100
+ * Astra F3 — A STORED CREDENTIAL IS NOT A SELECTED MODEL.
101
+ *
102
+ * `kiso login` exits 0, says the key is stored, and stops. With no profile
103
+ * the very next command still announces the demo and runs the scripted
104
+ * listing, so a person who has just signed in reasonably concludes that
105
+ * sign-in failed or is being ignored. Nothing was broken: a credential is
106
+ * stored under a PROVIDER, and a PROFILE is what selects the model that
107
+ * uses it. The gap was that nobody said so at the one moment it matters.
108
+ *
109
+ * These are onboarding EXAMPLES, not a resolution path — nothing here ever
110
+ * decides where a credential goes (that is `effectiveBaseUrl` and
111
+ * `providerIdOf`, and it stays there).
112
+ */
113
+ const PROFILE_EXAMPLE = {
114
+ anthropic: '{"model":"claude","models":{"claude":{"kind":"anthropic","model":"claude-sonnet-5"}}}',
115
+ openai: '{"model":"gpt","models":{"gpt":{"kind":"openai-compat","model":"gpt-4o"}}}',
116
+ chatgpt: '{"model":"codex","models":{"codex":{"kind":"openai-responses","model":"gpt-5-codex","baseUrl":"https://chatgpt.com/backend-api/codex"}}}',
117
+ deepseek: '{"model":"deepseek","models":{"deepseek":{"kind":"openai-compat","model":"deepseek-flash","baseUrl":"https://api.deepseek.com"}}}',
118
+ zai: '{"model":"glm","models":{"glm":{"kind":"openai-compat","model":"glm-5.3-flash","baseUrl":"https://api.z.ai"}}}',
119
+ };
120
+ /** The line a fresh sign-in needs, or nothing when profiles already exist.
121
+ * Read from the USER config alone: a credential command resolves no
122
+ * project trust, and profiles live in the user's own file. */
123
+ function nextStepAfterLogin(provider) {
124
+ let hasProfile = false;
125
+ try {
126
+ hasProfile = Object.keys(loadUserConfig()?.models ?? {}).length > 0;
127
+ }
128
+ catch {
129
+ // A config that cannot be read is its own loud error elsewhere; here
130
+ // it only means we cannot prove a profile exists, so we say the step.
131
+ }
132
+ if (hasProfile)
133
+ return "\nkiso already has profiles — `/model` in a session lists them and switches.\n";
134
+ const example = PROFILE_EXAMPLE[provider];
135
+ return (`\nThe key is stored, but nothing selects a model yet: a PROFILE does that,` +
136
+ ` and without one kiso stays in the keyless faux mode.\nPut one in ${join(kisoHome(), "config.json")}` +
137
+ (example === undefined ? ":\n" : `, for example:\n\n ${example}\n`) +
138
+ `\nThen run kiso. \`/model\` lists your profiles and switches between them.\n`);
139
+ }
99
140
  /** OR-9: the one install command the README gives, which `kiso update`
100
141
  * runs and the failure message names. */
101
142
  const INSTALL_COMMAND = ["npm", "i", "-g", "@vincemakes/kiso-code@latest"];
@@ -539,6 +580,13 @@ directory and change code with tools. Be concise: answer in a few lines
539
580
  unless the task genuinely needs more. Never claim a file was changed
540
581
  unless a tool confirmed it.
541
582
 
583
+ What you can reach:
584
+ - The workspace: read_file, list_dir, search_text, write_file, edit_file.
585
+ - This machine and the network: shell — builds, tests, git, package
586
+ managers, curl for HTTP, system queries. A request one command can
587
+ answer is answered by running it.
588
+ - The human: ask_user, for a decision that is theirs.
589
+
542
590
  Tool discipline:
543
591
  - READ BEFORE YOU EDIT. For any file you are about to change, read it
544
592
  first — never guess its content.
@@ -555,6 +603,12 @@ Tool discipline:
555
603
  - When a tool fails, read the error and adjust; do not repeat the same
556
604
  call blindly.
557
605
 
606
+ An authorization covers what it NAMES: when the scope is not named —
607
+ which files, which branches, whether to push — ask before acting.
608
+ Delivering, sharing or handing over changes means committing locally and
609
+ stopping; pushing, publishing or sending happens only when the human
610
+ names it.
611
+
558
612
  Workflow: understand the request, find the relevant code, make the
559
613
  smallest change that works, then verify with a command (tests/build).
560
614
  Report what you did in one or two lines per change.`;
@@ -664,16 +718,21 @@ async function makeAgent(sessionId, input, modelFlag) {
664
718
  // the panel bridge is the argument, and a non-TTY session has none to
665
719
  // give. A piped run's composed tool table therefore cannot contain
666
720
  // ask_user (T-Q3: the bench's structural byte-identity proof).
667
- const builtIn = await builtInLayer(user, proj, input !== undefined && process.stdin.isTTY ? askUi(input) : undefined);
721
+ // Astra F7: the config is READ here — a pure read, its setters still run
722
+ // below with the rest of merge round B — because the mcp extension spawns
723
+ // its stdio children while it is being constructed, and the strip needs
724
+ // the configured secret names by then.
725
+ const userCfg = loadUserConfig();
726
+ const projectCfg = loadProjectConfig(process.cwd(), project !== null);
727
+ const merged = mergeConfigs(userCfg, projectCfg);
728
+ const secretEnvNames = secretEnvNamesOf(merged.models ?? {});
729
+ const builtIn = await builtInLayer(user, proj, input !== undefined && process.stdin.isTTY ? askUi(input) : undefined, secretEnvNames);
668
730
  setExtensionLists(builtIn, user, proj, [...builtIn, ...user, ...proj]);
669
731
  // merge round B — the config surface: user config + (trusted) project config,
670
732
  // resolved with flags > env > project > user > default. The CLI never
671
733
  // imports provider SDKs directly — the runtime's lazy provider
672
734
  // resolution owns them (a config profile only ever NAMES an env var for
673
735
  // its key; the key itself never sits in a config file).
674
- const userCfg = loadUserConfig();
675
- const projectCfg = loadProjectConfig(process.cwd(), project !== null);
676
- const merged = mergeConfigs(userCfg, projectCfg);
677
736
  setMergedConfig(merged);
678
737
  // DT-1a: what a delegated task may NAME — the configured checks and the
679
738
  // model profiles — handed to the (in-process) subagent extension through
@@ -741,21 +800,18 @@ async function makeAgent(sessionId, input, modelFlag) {
741
800
  ...(resolved !== null
742
801
  ? {
743
802
  provider: resolved.profile.kind,
744
- // OR-1: exactly one sign-in shape reaches the adapter. An
745
- // OAuth profile has no key to pass — it passes the thunk
746
- // the adapter re-resolves per request instead.
747
- ...(resolved.oauthProviderId !== undefined
748
- ? { oauth: oauthTokenThunk(resolved.oauthProviderId) }
749
- : { apiKey: resolved.apiKey ?? "none" }),
750
- // OR-1: the ChatGPT backend's cache lane is the SESSION —
751
- // one conversation's requests share a key, different
752
- // conversations never do. The one entry point that hands
753
- // no session id is `kiso sessions`, a read-only listing
754
- // that streams nothing, so its absence costs no cache.
755
- ...(sessionId !== undefined ? { promptCacheKey: sessionId } : {}),
756
- ...(resolved.profile.baseUrl !== undefined ? { baseUrl: resolved.profile.baseUrl } : {}),
757
- ...(resolved.profile.promptCaching !== undefined ? { promptCaching: resolved.profile.promptCaching } : {}),
758
- // LT-1: the profile's stream watchdog bound, if it states one
803
+ // Astra F1 (P0): the wire config is built in ONE place
804
+ // (auth/adapter-options.ts) — credential shape, the ALWAYS
805
+ // EXPLICIT endpoint, the session's cache key and the
806
+ // profile's caching flag. This site and the `/model` site
807
+ // were the same spread written twice.
808
+ ...adapterOptionsFor(resolved.profile, resolved.oauthProviderId !== undefined
809
+ ? { type: "oauth", providerId: resolved.oauthProviderId }
810
+ : { type: "api-key", apiKey: resolved.apiKey ?? "none" }, sessionId),
811
+ // LT-1: the profile's stream watchdog bound, if it states one.
812
+ // NOT an adapter option — an agent-definition field the
813
+ // runtime reads — so it stays here rather than moving into
814
+ // adapterOptionsFor.
759
815
  ...(resolved.profile.streamIdleMs !== undefined ? { streamIdleMs: resolved.profile.streamIdleMs } : {}),
760
816
  }
761
817
  : { adapter: createFauxProvider(readFauxScript().slice(fauxSkipTurns)) }),
@@ -1296,7 +1352,12 @@ async function main() {
1296
1352
  input.onClipboardPaste?.(() => {
1297
1353
  const shot = clipboardImage(tmpdir());
1298
1354
  if (shot === null) {
1299
- bodyLog("[no image on the clipboard — ctrl+V attaches one; a file dragged into the window works too]");
1355
+ // Astra F8: on a platform with no clipboard reader kiso never
1356
+ // LOOKED, so "no image on the clipboard" blamed the clipboard for
1357
+ // a limit of the build. Say which of the two it is.
1358
+ bodyLog(process.platform === "darwin"
1359
+ ? "[no image on the clipboard — ctrl+V attaches one; a file dragged into the window works too]"
1360
+ : "[clipboard images are macOS-only — put the image's path in your message instead; a file dragged into the window leaves one]");
1300
1361
  return null;
1301
1362
  }
1302
1363
  return shot;
@@ -1465,6 +1526,7 @@ async function main() {
1465
1526
  });
1466
1527
  setCredential(provider, cred);
1467
1528
  process.stdout.write(`signed in to ${provider} (${chatgptFlow.label}) — account ${cred.accountId ?? "?"}, expires ${new Date(cred.expires).toISOString()} — stored in ${authPath()}\n`);
1529
+ process.stdout.write(nextStepAfterLogin(provider));
1468
1530
  }
1469
1531
  finally {
1470
1532
  rl.close();
@@ -1476,6 +1538,7 @@ async function main() {
1476
1538
  throw new CliUsageError(`kiso login ${provider}: no key given`);
1477
1539
  setCredential(provider, { type: "api-key", key, savedAt: Date.now() });
1478
1540
  process.stdout.write(`signed in to ${provider} with an API key (${maskSecret(key)}) — stored in ${authPath()}\n`);
1541
+ process.stdout.write(nextStepAfterLogin(provider));
1479
1542
  break;
1480
1543
  }
1481
1544
  case "update": {
package/dist/mode.d.ts CHANGED
@@ -15,7 +15,16 @@ export declare const MODES: readonly Mode[];
15
15
  /** DC-36 — one line per tier, for the picker, TRANSCRIBED FROM decide()
16
16
  * below rather than written fresh. A description that drifts from the
17
17
  * behaviour is worse than none: this is the row a human reads before
18
- * handing over the approval gate. */
18
+ * handing over the approval gate.
19
+ *
20
+ * Astra F4: these describe THE TIER'S CONTRIBUTION TO THE CHAIN, not the
21
+ * chain's verdict. "every tool asks" read as a promise that manual is a
22
+ * revocation — pick it and you are asked about everything again — and it
23
+ * is not. A tier that ASKS abstains in favour of anything that ALLOWS, so
24
+ * a saved don't-ask-again rule still allows, and the side effect runs with
25
+ * no new question. The composition (deny > allow > ask) is the ruling and
26
+ * is unchanged; the copy was the thing that was wrong. `plan` needs no
27
+ * qualification because it DENIES, and a deny is what nothing overrides. */
19
28
  export declare const MODE_NOTE: Readonly<Record<Mode, string>>;
20
29
  export declare function getMode(): Mode;
21
30
  export declare function setMode(m: Mode): void;
package/dist/mode.js CHANGED
@@ -13,13 +13,22 @@ export const MODES = ["manual", "default", "accept-edits", "plan", "bypass"];
13
13
  /** DC-36 — one line per tier, for the picker, TRANSCRIBED FROM decide()
14
14
  * below rather than written fresh. A description that drifts from the
15
15
  * behaviour is worse than none: this is the row a human reads before
16
- * handing over the approval gate. */
16
+ * handing over the approval gate.
17
+ *
18
+ * Astra F4: these describe THE TIER'S CONTRIBUTION TO THE CHAIN, not the
19
+ * chain's verdict. "every tool asks" read as a promise that manual is a
20
+ * revocation — pick it and you are asked about everything again — and it
21
+ * is not. A tier that ASKS abstains in favour of anything that ALLOWS, so
22
+ * a saved don't-ask-again rule still allows, and the side effect runs with
23
+ * no new question. The composition (deny > allow > ask) is the ruling and
24
+ * is unchanged; the copy was the thing that was wrong. `plan` needs no
25
+ * qualification because it DENIES, and a deny is what nothing overrides. */
17
26
  export const MODE_NOTE = {
18
- manual: "every tool asks",
19
- default: "reads run; writes, edits and shell ask",
20
- "accept-edits": "reads, writes and edits run; shell asks",
21
- plan: "reads run; everything else is denied — read-only",
22
- bypass: "everything runs, nothing asks",
27
+ manual: "asks for every tool — a saved allow still allows",
28
+ default: "reads run; writes, edits and shell ask — a saved allow still allows",
29
+ "accept-edits": "reads, writes and edits run; shell asks — a saved allow still allows",
30
+ plan: "reads run; everything else is denied — read-only, and a deny wins",
31
+ bypass: "everything runs, nothing asks — a user deny still wins",
23
32
  };
24
33
  /** The read-only tool set (plan): reading is allowed, everything else
25
34
  * denied with the guiding reason. */
package/dist/state.d.ts CHANGED
@@ -16,9 +16,19 @@ export declare function kisoHome(): string;
16
16
  * is decided. The session's own tool set and the `!` command's runner both
17
17
  * need it, and two copies of a ruling-bearing literal is how a later DC-49
18
18
  * amendment reaches one caller and not the other. */
19
+ /**
20
+ * Astra F7 — every configured profile's `apiKeyEnv` NAME is a secret name.
21
+ * The strip's suffix rules cannot see `REVIEW_PROVIDER_TOKEN`, and only the
22
+ * config knows which names are keys, so the config says so. ONE derivation:
23
+ * the shell tool reads it through codingToolOptions, and startup reads it
24
+ * directly for the mcp extension, which spawns its stdio children before
25
+ * these state setters have run.
26
+ */
27
+ export declare function secretEnvNamesOf(models: Readonly<Record<string, unknown>>): readonly string[];
19
28
  export declare function codingToolOptions(): {
20
29
  readonly workspaceRoot: string;
21
30
  readonly excludeRoots: readonly string[];
31
+ readonly secretEnvNames: readonly string[];
22
32
  };
23
33
  export declare function sessionsDir(): string;
24
34
  /** E1: the extension scan directory — KISO_EXTENSIONS_DIR overrides. */
package/dist/state.js CHANGED
@@ -22,8 +22,23 @@ export function kisoHome() {
22
22
  * is decided. The session's own tool set and the `!` command's runner both
23
23
  * need it, and two copies of a ruling-bearing literal is how a later DC-49
24
24
  * amendment reaches one caller and not the other. */
25
+ /**
26
+ * Astra F7 — every configured profile's `apiKeyEnv` NAME is a secret name.
27
+ * The strip's suffix rules cannot see `REVIEW_PROVIDER_TOKEN`, and only the
28
+ * config knows which names are keys, so the config says so. ONE derivation:
29
+ * the shell tool reads it through codingToolOptions, and startup reads it
30
+ * directly for the mcp extension, which spawns its stdio children before
31
+ * these state setters have run.
32
+ */
33
+ export function secretEnvNamesOf(models) {
34
+ return [
35
+ ...new Set(Object.values(models)
36
+ .map((p) => p.apiKeyEnv)
37
+ .filter((n) => typeof n === "string" && n.length > 0)),
38
+ ];
39
+ }
25
40
  export function codingToolOptions() {
26
- return { workspaceRoot: process.cwd(), excludeRoots: [kisoHome()] };
41
+ return { workspaceRoot: process.cwd(), excludeRoots: [kisoHome()], secretEnvNames: secretEnvNamesOf(configModels) };
27
42
  }
28
43
  export function sessionsDir() {
29
44
  return join(kisoHome(), "sessions");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.33.0",
4
- "description": "kiso-code — the coding agent on kiso, the durable runtime for AI agents: kiso chat / kiso resume / kiso sessions.",
3
+ "version": "0.36.0",
4
+ "description": "kiso-code \u2014 the coding agent on kiso, the durable runtime for AI agents: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -18,20 +18,20 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-ask-ext": "0.33.0",
22
- "@vincemakes/kiso-core": "0.33.0",
23
- "@vincemakes/kiso-evals": "0.33.0",
24
- "@vincemakes/kiso-mcp-ext": "0.33.0",
25
- "@vincemakes/kiso-provider-anthropic": "0.33.0",
26
- "@vincemakes/kiso-provider-openai": "0.33.0",
27
- "@vincemakes/kiso-provider-openai-responses": "0.33.0",
28
- "@vincemakes/kiso-runtime": "0.33.0",
29
- "@vincemakes/kiso-skills-ext": "0.33.0",
30
- "@vincemakes/kiso-subagent-ext": "0.33.0",
31
- "@vincemakes/kiso-task-ext": "0.33.0",
32
- "@vincemakes/kiso-tools-node": "0.33.0",
33
- "@vincemakes/kiso-tui": "0.33.0",
34
- "@vincemakes/kiso-tui-cells": "0.33.0"
21
+ "@vincemakes/kiso-ask-ext": "0.36.0",
22
+ "@vincemakes/kiso-core": "0.36.0",
23
+ "@vincemakes/kiso-evals": "0.36.0",
24
+ "@vincemakes/kiso-mcp-ext": "0.36.0",
25
+ "@vincemakes/kiso-provider-anthropic": "0.36.0",
26
+ "@vincemakes/kiso-provider-openai": "0.36.0",
27
+ "@vincemakes/kiso-provider-openai-responses": "0.36.0",
28
+ "@vincemakes/kiso-runtime": "0.36.0",
29
+ "@vincemakes/kiso-skills-ext": "0.36.0",
30
+ "@vincemakes/kiso-subagent-ext": "0.36.0",
31
+ "@vincemakes/kiso-task-ext": "0.36.0",
32
+ "@vincemakes/kiso-tools-node": "0.36.0",
33
+ "@vincemakes/kiso-tui": "0.36.0",
34
+ "@vincemakes/kiso-tui-cells": "0.36.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^26.1.2",