@vincemakes/kiso-code 0.13.0 → 0.15.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/README.md CHANGED
@@ -1,9 +1,54 @@
1
1
  # @vincemakes/kiso-code
2
2
 
3
- The coding-agent reference product: kiso chat / kiso resume / kiso
4
- sessions. Install it or run directly with npx @vincemakes/kiso-code. Keyless faux
5
- mode out of the box; ANTHROPIC_API_KEY or OPENAI_API_KEY + OPENAI_BASE_URL
6
- switch to real providers.
3
+ The coding agent that survives kill -9: durable sessions (append-only
4
+ JSONL), pre-effect approvals, and honest recovery an interrupted side
5
+ effect is surfaced and asked about, never silently re-run.
7
6
 
8
- Requires Node >= 22. See the repository README for the framework
9
- overview.
7
+ ## Install and first session
8
+
9
+ ```sh
10
+ npm install -g @vincemakes/kiso-code
11
+ kiso # or: npx @vincemakes/kiso-code
12
+ ```
13
+
14
+ With no key set you get **faux mode** — a scripted four-round demo of
15
+ the full CLI (tools, approvals, sessions), zero keys. When the script
16
+ runs out (about two user turns) the session exits with a set-a-key
17
+ message; that exit is the design, not a crash.
18
+
19
+ ## A real model
20
+
21
+ ```sh
22
+ # OpenAI-compatible (checked first; the key alone talks to OpenAI —
23
+ # OPENAI_BASE_URL optionally retargets DeepSeek or any compat endpoint):
24
+ export OPENAI_API_KEY=... # OPENAI_MODEL (default gpt-4o)
25
+
26
+ # or Anthropic:
27
+ export ANTHROPIC_API_KEY=... # ANTHROPIC_MODEL (default claude-sonnet-5)
28
+
29
+ kiso
30
+ ```
31
+
32
+ Named model profiles live in `~/.kiso/config.json` (the config stores
33
+ the NAME of the env var holding each key, never the key itself); switch
34
+ in-session with `/model`. Approval tiers: `--mode
35
+ manual|default|accept-edits|plan|bypass` or `/mode` in-session.
36
+
37
+ ## The commands
38
+
39
+ ```text
40
+ kiso [sessionId] interactive session (default command)
41
+ kiso resume pick a session to continue (TTY picker)
42
+ kiso resume <id> ["prompt"] continue a session, one-shot
43
+ kiso sessions list durable sessions
44
+ kiso help usage + configuration reference
45
+ ```
46
+
47
+ Sessions live under `~/.kiso/sessions`; kill the process — `kill -9`
48
+ included — and `kiso resume` continues exactly where the log ends.
49
+
50
+ **Platforms:** macOS / Linux (Node ≥ 22). Windows is unsupported.
51
+
52
+ Full documentation — extensions (MCP, skills, subagents), the approval
53
+ model, session recovery, configuration reference — in the
54
+ [repository README](https://github.com/vincemakes/kiso#readme).
package/dist/chat.d.ts CHANGED
@@ -76,7 +76,7 @@ export interface UsageDelta {
76
76
  * absent provider resolves like the tracer's "adapter" identity — the
77
77
  * total convention (INPUT_CONVENTIONS), never a crash.
78
78
  */
79
- export declare function usageFromEvent(route: string | undefined, ev: import("@vincemakes/kiso-core").Usage, prevTotal: number | null): UsageDelta;
79
+ export declare function usageFromEvent(route: string | undefined, ev: import("@vincemakes/kiso-core").Usage, prevTotal: number | null, model?: string): UsageDelta;
80
80
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
81
81
  * is gone) — docked only, 200ms rotation between the request and the
82
82
  * first event. */
package/dist/chat.js CHANGED
@@ -5,12 +5,14 @@
5
5
  * estimates. All bodies moved verbatim from index.ts.
6
6
  */
7
7
  import { readFileSync, statSync } from "node:fs";
8
- import { escapeTerminal, cacheHitPct, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
8
+ import { escapeTerminal, cacheHitPct, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, verifyOfferView, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
9
9
  import { deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
11
  import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
12
+ import { canonicalizeUsageForModel } from "@vincemakes/kiso-runtime/internal";
12
13
  import { dispatch } from "./dispatch.js";
13
14
  import { agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
15
+ import { lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
14
16
  import { addDontAskAgainRule, askPanel, fixHintFor, pendingAsk, resolveUncertains } from "./trust-ui.js";
15
17
  import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
16
18
  import { MODES, getMode, setMode } from "./mode.js";
@@ -37,7 +39,18 @@ export function contextWindowTokens() {
37
39
  if (windowOverride !== undefined)
38
40
  return windowOverride;
39
41
  const window = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
40
- return Number.isFinite(window) && window > 0 ? window : DEFAULT_CONTEXT_WINDOW;
42
+ if (Number.isFinite(window) && window > 0)
43
+ return window;
44
+ // PH-1c (finding PH-F15): the window follows the LIVE model when the
45
+ // metadata registry knows it — /model to a known model moves the
46
+ // window (and the microcompact threshold derived from it) without an
47
+ // env var. agentModel is the same live binding the status row shows;
48
+ // an unknown model keeps the 200k default — the registry never
49
+ // guesses, so neither do we.
50
+ const known = lookupModelMetadata(agentModel)?.capabilities.contextWindow;
51
+ if (known !== undefined && known !== null)
52
+ return known;
53
+ return DEFAULT_CONTEXT_WINDOW;
41
54
  }
42
55
  /**
43
56
  * B area: approximate context ratio — chars/4 of the projected messages vs
@@ -78,8 +91,13 @@ const CACHE_MISS_FLOOR = 1024;
78
91
  * absent provider resolves like the tracer's "adapter" identity — the
79
92
  * total convention (INPUT_CONVENTIONS), never a crash.
80
93
  */
81
- export function usageFromEvent(route, ev, prevTotal) {
82
- const c = canonicalizeUsage(route ?? "adapter", ev);
94
+ export function usageFromEvent(route, ev, prevTotal,
95
+ // PH-1c: the LIVE model — when given, the $cost keys on the model's
96
+ // metadata entry (an unpriced model shows null, never a route-table
97
+ // guess); omitted, the legacy route-keyed path stands (old callers,
98
+ // old tests, unchanged bytes).
99
+ model) {
100
+ const c = model === undefined ? canonicalizeUsage(route ?? "adapter", ev) : canonicalizeUsageForModel(model, undefined, route ?? "adapter", ev);
83
101
  const total = c.input + c.cacheRead + (c.cacheWrite ?? 0);
84
102
  let missed = null;
85
103
  // R-C item 4: min(prevTotal, total) is the part that could have been
@@ -342,6 +360,21 @@ export function parseSaferOptions(text) {
342
360
  }
343
361
  return out.length === 0 ? null : out;
344
362
  }
363
+ /** TV-1B — the plain-word verdict tail for the settled checklist.
364
+ * "no passing check yet" covers both never-ran and ran-and-failed
365
+ * without lying; "outdated" claims only what the trajectory proves. */
366
+ function taskVerdictWords(kind) {
367
+ switch (kind) {
368
+ case "verified":
369
+ return "checked \u2713";
370
+ case "stale":
371
+ return "check outdated \u2014 work may have changed after it";
372
+ case "none":
373
+ return "no passing check yet";
374
+ case "unreadable":
375
+ return "task list unreadable";
376
+ }
377
+ }
345
378
  /** W21 — the panel view for a permission_requested: the rule line (the
346
379
  * why-asked speaker + the §3.5 fix hint), the toolTarget title, the
347
380
  * "▸ run paused" status, and the ALWAYS-verbose args. */
@@ -510,6 +543,15 @@ submitTurn) {
510
543
  // construction (ADR-0040).
511
544
  switch (ev.type) {
512
545
  case "user_input":
546
+ // TV-1B: a system-sourced input is PRODUCT MACHINERY — visible
547
+ // (every durable input renders) but never painted as the
548
+ // user's words. Provenance is honest on screen, not only in
549
+ // the log.
550
+ if (ev.source === "system") {
551
+ body.notice("\u25c6 verification pass");
552
+ body.notice(` ${typeof ev.content === "string" ? ev.content : ""}`);
553
+ break;
554
+ }
513
555
  body.userLine(typeof ev.content === "string" ? ev.content : "");
514
556
  break;
515
557
  case "thinking":
@@ -572,7 +614,7 @@ submitTurn) {
572
614
  body.textEnd();
573
615
  break;
574
616
  case "usage": {
575
- const delta = usageFromEvent(session.provider, ev, prevTotal);
617
+ const delta = usageFromEvent(session.provider, ev, prevTotal, agentModel);
576
618
  usage = delta.usage;
577
619
  prevTotal = delta.total;
578
620
  missed = delta.missed;
@@ -687,6 +729,19 @@ submitTurn) {
687
729
  // events (zero tokens). The dock's status bar still paints.
688
730
  statusCb?.(usage, estimateCtxRatio(session));
689
731
  const ratio = estimateCtxRatio(session);
732
+ // TV-1B: the settle verdict — the checklist stops lying. When
733
+ // every item is CLAIMED done, the settled block's tail says
734
+ // what the projection actually proves ("no passing check yet"
735
+ // covers never-ran AND ran-and-failed; "may have changed"
736
+ // claims trajectory knowledge, never filesystem knowledge).
737
+ // A run that emitted no task_set still gets the block: the
738
+ // claims live in the durable log, and the settle SYNTHESIZES
739
+ // the display from session.assessTasks() — a UI projection,
740
+ // never a new durable fact.
741
+ const tv = session.assessTasks();
742
+ if (tv.claims.length > 0 && tv.allClaimedDone) {
743
+ body.checklist(taskVerdictWords(tv.evidence.kind), tv.claims.map((c) => ({ text: c.text, status: c.status })));
744
+ }
690
745
  // W14: the turn record closes HERE — before the recap logs, so
691
746
  // the commit loop folds the quiet turn's held cells first (the
692
747
  // fold line lands above the recap, natural cell order).
@@ -759,9 +814,9 @@ export async function chat(session, faux, input, autoCompact) {
759
814
  console.log("\n[exit requested]");
760
815
  input.close();
761
816
  };
762
- const turn = (text) => new Promise((resolve, reject) => {
817
+ const turn = (text, seedSource) => new Promise((resolve, reject) => {
763
818
  queued = Math.max(0, queued - 1); // a queued turn starts
764
- const run = session.run(text);
819
+ const run = seedSource !== undefined ? session.run(text, { source: seedSource }) : session.run(text);
765
820
  currentRun = run;
766
821
  turnNo += 1;
767
822
  const myTurn = turnNo;
@@ -792,6 +847,41 @@ export async function chat(session, faux, input, autoCompact) {
792
847
  // exit, so the release always runs.
793
848
  if (eotSeen)
794
849
  exitAtEmptyPrompt();
850
+ // TV-1B — the thin task driver. A VERIFICATION turn settling
851
+ // consumes the task-set identity it produced (a verifier's
852
+ // own task_set belongs to the SAME accepted offer) and never
853
+ // opens another offer. A normal COMPLETED settle may offer —
854
+ // gated so the suggestion always yields to human intent.
855
+ if (seedSource === "system") {
856
+ const after = session.assessTasks();
857
+ if (after.lastTaskSetSeq !== null)
858
+ offeredTaskSeqs.add(after.lastTaskSetSeq);
859
+ }
860
+ else if (last?.type === "terminal" &&
861
+ last.outcome.kind === "completed" &&
862
+ process.stdin.isTTY &&
863
+ !cancelled &&
864
+ !eotSeen &&
865
+ pendingAsk === null &&
866
+ pendingTurns.length === 0 &&
867
+ input.line() === "") {
868
+ const tv = session.assessTasks();
869
+ if (tv.claims.length > 0 &&
870
+ tv.allClaimedDone &&
871
+ (tv.evidence.kind === "none" || tv.evidence.kind === "stale") &&
872
+ tv.lastTaskSetSeq !== null &&
873
+ !offeredTaskSeqs.has(tv.lastTaskSetSeq)) {
874
+ const verdict = await askPanel(input, verifyOfferView());
875
+ // an explicit answer — Yes, Not now, OR Esc — consumes
876
+ // the offer for THIS claims-set; only gate-suppression
877
+ // (above) leaves it live for a later settle.
878
+ offeredTaskSeqs.add(tv.lastTaskSetSeq);
879
+ if (verdict.action === "allow") {
880
+ queued += 1; // turn() decrements — keep the ledger honest
881
+ chainRef.current = chainRef.current.then(() => turn(VERIFY_SEED, "system"));
882
+ }
883
+ }
884
+ }
795
885
  // round 8: after EVERY turn the prompt is re-armed — the human
796
886
  // never types blind after the first turn.
797
887
  input.prompt();
@@ -896,6 +986,14 @@ export async function chat(session, faux, input, autoCompact) {
896
986
  // A slot leaves the queue when its turn STARTS or when the user
897
987
  // pops it (cancelled — the chain segment skips it).
898
988
  const pendingTurns = [];
989
+ // TV-1B — the offer memory: session-local BY DESIGN (a dead process's
990
+ // "not now" should not silence a live one; resume re-offers once,
991
+ // honestly), keyed by the assessed claims' identity.
992
+ const offeredTaskSeqs = new Set();
993
+ // The fixed verification seed — durable with source:"system": WHO asked
994
+ // is provenance in the log; on the provider wire it stays an ordinary
995
+ // user-role message (never a system-prompt escalation).
996
+ const VERIFY_SEED = "Verify the completed work: run the project's checks and report what passes and what fails.";
899
997
  // v2b: the live status bar (docked only). Modes: /mode switches repaint
900
998
  // it immediately through paintStatus (the last turn stats are kept).
901
999
  // v3 §03: the status bar has TWO states. Idle: the mode is ALWAYS
package/dist/config.d.ts CHANGED
@@ -32,8 +32,15 @@ export interface ModelProfile {
32
32
  readonly kind: ProfileKind;
33
33
  readonly baseUrl?: string;
34
34
  readonly model: string;
35
- /** The env var NAME holding the key — never the key itself. */
36
- readonly apiKeyEnv: string;
35
+ /** The env var NAME holding the key — never the key itself.
36
+ * PH-1c (finding PH-F19): OPTIONAL — an absent apiKeyEnv means an
37
+ * unauthenticated endpoint (a local Ollama, a LAN proxy); the
38
+ * adapter receives a placeholder key, and the profile no longer
39
+ * demands a dummy env var to exist. */
40
+ readonly apiKeyEnv?: string;
41
+ /** PH-1c.1: opt-in Anthropic prompt caching for this profile —
42
+ * default off (a request-byte cost behavior never flips silently). */
43
+ readonly promptCaching?: boolean;
37
44
  }
38
45
  export interface AutoCompactConfig {
39
46
  readonly thresholdRatio: number;
package/dist/config.js CHANGED
@@ -68,15 +68,18 @@ export function parseConfig(text, source) {
68
68
  fail(`models.${name}.kind`, `expected one of ${KINDS.join(", ")}`);
69
69
  if (typeof p.model !== "string" || p.model === "")
70
70
  fail(`models.${name}.model`, "expected a model string");
71
- if (typeof p.apiKeyEnv !== "string" || p.apiKeyEnv === "")
72
- fail(`models.${name}.apiKeyEnv`, "expected an env var name (the config never stores keys)");
71
+ if (p.apiKeyEnv !== undefined && (typeof p.apiKeyEnv !== "string" || p.apiKeyEnv === ""))
72
+ fail(`models.${name}.apiKeyEnv`, "expected an env var name (the config never stores keys); omit it entirely for an unauthenticated local endpoint");
73
73
  if (p.baseUrl !== undefined && typeof p.baseUrl !== "string")
74
74
  fail(`models.${name}.baseUrl`, "expected a string");
75
+ if (p.promptCaching !== undefined && typeof p.promptCaching !== "boolean")
76
+ fail(`models.${name}.promptCaching`, "expected a boolean");
75
77
  models[name] = {
76
78
  kind: p.kind,
77
79
  model: p.model,
78
- apiKeyEnv: p.apiKeyEnv,
80
+ ...(typeof p.apiKeyEnv === "string" ? { apiKeyEnv: p.apiKeyEnv } : {}),
79
81
  ...(typeof p.baseUrl === "string" ? { baseUrl: p.baseUrl } : {}),
82
+ ...(typeof p.promptCaching === "boolean" ? { promptCaching: p.promptCaching } : {}),
80
83
  };
81
84
  }
82
85
  out.models = models;
@@ -151,7 +154,9 @@ export function mergeConfigs(user, project) {
151
154
  }
152
155
  /** A profile is available when its apiKeyEnv var is set (the key exists). */
153
156
  export function profileAvailable(p) {
154
- return process.env[p.apiKeyEnv] !== undefined;
157
+ // PH-1c (finding PH-F19): a keyless profile (no apiKeyEnv) is an
158
+ // unauthenticated endpoint — always available.
159
+ return p.apiKeyEnv === undefined || process.env[p.apiKeyEnv] !== undefined;
155
160
  }
156
161
  /** "provider/model" direct write → a profile. */
157
162
  export function directWriteProfile(value) {
@@ -205,6 +210,9 @@ export function resolveModel(modelFlag, merged) {
205
210
  kind: "anthropic",
206
211
  model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5",
207
212
  apiKeyEnv: "ANTHROPIC_API_KEY",
213
+ // PH-1c (finding PH-F19): symmetric with OPENAI_BASE_URL —
214
+ // proxies and compat gateways serve the anthropic dialect too.
215
+ ...(process.env.ANTHROPIC_BASE_URL !== undefined ? { baseUrl: process.env.ANTHROPIC_BASE_URL } : {}),
208
216
  },
209
217
  apiKey: process.env.ANTHROPIC_API_KEY,
210
218
  };
@@ -226,7 +234,9 @@ function resolveProfile(name, p) {
226
234
  if (!profileAvailable(p)) {
227
235
  throw new ConfigError(`model ${name}: unavailable — the env var ${p.apiKeyEnv} is not set (configs never store keys, only the env-var name)`);
228
236
  }
229
- return { name, profile: p, apiKey: process.env[p.apiKeyEnv] };
237
+ // PH-1c (finding PH-F19): a keyless profile hands the adapter a
238
+ // placeholder — the SDKs require SOME string; the endpoint ignores it.
239
+ return { name, profile: p, apiKey: p.apiKeyEnv === undefined ? "none" : process.env[p.apiKeyEnv] };
230
240
  }
231
241
  /** Mode: env (KISO_MODE) beats config.mode; the --mode flag is applied by
232
242
  * main before this runs (flags are the top of the chain). */
package/dist/dispatch.js CHANGED
@@ -193,7 +193,11 @@ export function dispatch(line, ctx) {
193
193
  const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : [])];
194
194
  return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · ") };
195
195
  }),
196
- typeHint: names.length === 0 ? "type provider/model directly (e.g. openai/deepseek-reasoner)" : "type provider/model directly",
196
+ // PH-1a (finding PH-F4): the example must be a syntax
197
+ // directWriteProfile actually ACCEPTS — the old
198
+ // "openai/…" hint failed with "no such model profile"
199
+ // on exactly the fresh-install path that shows it.
200
+ typeHint: names.length === 0 ? "type provider/model directly (e.g. openai-compat/deepseek-reasoner)" : "type provider/model directly",
197
201
  // the zero-profile copy is TODAY'S, verbatim: the
198
202
  // user who sees it is exactly the user who needs
199
203
  // the path spelled out
@@ -233,10 +237,19 @@ export function dispatch(line, ctx) {
233
237
  }
234
238
  else {
235
239
  const adapter = await buildAdapter(profile.kind, {
236
- apiKey: process.env[profile.apiKeyEnv],
240
+ // PH-1c (PH-F19): a keyless profile = an unauthenticated
241
+ // endpoint — the placeholder satisfies the SDK's ctor.
242
+ apiKey: profile.apiKeyEnv === undefined ? "none" : process.env[profile.apiKeyEnv],
237
243
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
244
+ ...(profile.promptCaching !== undefined ? { promptCaching: profile.promptCaching } : {}),
238
245
  });
239
- ctx.session.setAdapter(adapter);
246
+ // PH-1a (finding PH-F8, P0): the switch is ATOMIC —
247
+ // adapter, model id, and provider route move together.
248
+ // setAdapter alone left the session's frozen config
249
+ // carrying the OLD model, so the status row claimed the
250
+ // new model while every request still sent the old id
251
+ // (and usage canonicalized under the old route).
252
+ ctx.session.setModelBinding({ adapter, model: profile.model, provider: profile.kind });
240
253
  setAgentModel(profile.model);
241
254
  setCurrentModelName(arg);
242
255
  body.notice(`model → ${arg} (${profile.model}) — takes effect on the next turn`);
@@ -336,9 +349,38 @@ export function dispatch(line, ctx) {
336
349
  return;
337
350
  }
338
351
  if (trimmed === "exit" || trimmed === "") {
352
+ // PH-1a (finding PH-F11): closing the input MID-RUN killed the very
353
+ // surface a later approval would ask through ("readline was
354
+ // closed", the v2b-era edge). On the DOCKED interactive surface the
355
+ // close now queues on the chain — the run finishes (approvals and
356
+ // all), then the REPL exits. DOCK-GATED on purpose: the pipe path
357
+ // is byte-pinned (a notice there would corrupt machine-read
358
+ // streams), a pipe's "exit" always arrives mid-run, and the
359
+ // approval panel the fix protects only exists on the dock. An idle
360
+ // exit is immediate, byte-for-byte as before, on every surface.
361
+ if (ctx.isRunning() && dock.active) {
362
+ if (trimmed === "exit")
363
+ body.notice("[exit queued — closing after the current run completes]");
364
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
365
+ ctx.input.close();
366
+ });
367
+ return;
368
+ }
339
369
  ctx.input.close();
340
370
  return;
341
371
  }
372
+ // PH-1a (finding PH-F1): an unrecognized slash command is an ERROR,
373
+ // never a turn — the fallthrough used to hand "/clear", "/exit", or a
374
+ // typo to the model, burning a request on text the user meant as a
375
+ // command. A multi-line paste that merely begins with "/" is prose and
376
+ // still submits.
377
+ if (trimmed.startsWith("/") && !trimmed.includes("\n")) {
378
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
379
+ bodyLog(`unknown command: ${escapeTerminal(trimmed.split(" ")[0] ?? trimmed)} — /help lists the commands`);
380
+ ctx.input.prompt();
381
+ });
382
+ return;
383
+ }
342
384
  // v2c: a turn submitted while another runs waits on the chain — the
343
385
  // live count rides the status bar (+N queued).
344
386
  ctx.submitTurn(line);
package/dist/index.d.ts CHANGED
@@ -6,9 +6,10 @@
6
6
  * kiso resume <sessionId> continue a session in one-shot mode
7
7
  * kiso sessions list durable sessions
8
8
  *
9
- * Provider selection (first match):
9
+ * Provider selection (first match — PH-1b corrected this header: the
10
+ * code has always checked OPENAI first, config.ts resolveModel):
11
+ * OPENAI_API_KEY → OpenAI-compatible (OPENAI_MODEL default gpt-4o, OPENAI_BASE_URL)
10
12
  * ANTHROPIC_API_KEY → Anthropic (ANTHROPIC_MODEL, default claude-sonnet-5)
11
- * OPENAI_API_KEY → OpenAI-compatible (OPENAI_MODEL, OPENAI_BASE_URL)
12
13
  * neither → faux mode: scripted model, zero keys, full CLI
13
14
  *
14
15
  * Sessions live under $KISO_HOME/sessions (default ~/.kiso/sessions) as
package/dist/index.js CHANGED
@@ -6,9 +6,10 @@
6
6
  * kiso resume <sessionId> continue a session in one-shot mode
7
7
  * kiso sessions list durable sessions
8
8
  *
9
- * Provider selection (first match):
9
+ * Provider selection (first match — PH-1b corrected this header: the
10
+ * code has always checked OPENAI first, config.ts resolveModel):
11
+ * OPENAI_API_KEY → OpenAI-compatible (OPENAI_MODEL default gpt-4o, OPENAI_BASE_URL)
10
12
  * ANTHROPIC_API_KEY → Anthropic (ANTHROPIC_MODEL, default claude-sonnet-5)
11
- * OPENAI_API_KEY → OpenAI-compatible (OPENAI_MODEL, OPENAI_BASE_URL)
12
13
  * neither → faux mode: scripted model, zero keys, full CLI
13
14
  *
14
15
  * Sessions live under $KISO_HOME/sessions (default ~/.kiso/sessions) as
@@ -26,7 +27,7 @@ import { readFileSync, realpathSync, rmSync } from "node:fs";
26
27
  import { createInterface } from "node:readline";
27
28
  import { fileURLToPath } from "node:url";
28
29
  import { join } from "node:path";
29
- import { Body, Editor, bannerLines, escapeTerminal, extensionsBannerText, idColumn, idleStatus, interactivePrompt, palette, renderSessionLine, sessionListFooter, sessionListRow } from "@vincemakes/kiso-tui";
30
+ import { BADGE_GLYPH, Body, Editor, bannerLines, escapeTerminal, extensionsBannerText, idColumn, idleStatus, interactivePrompt, palette, renderSessionLine, sessionListFooter, sessionListRow } from "@vincemakes/kiso-tui";
30
31
  import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, SessionStore, } from "@vincemakes/kiso-runtime";
31
32
  import { createFauxProvider } from "@vincemakes/kiso-evals";
32
33
  import { createCodingTools } from "@vincemakes/kiso-tools-node";
@@ -39,7 +40,7 @@ import { fauxSkip, readFauxScript } from "./faux-glue.js";
39
40
  import { autoCompactFromEnv, chat, contextWindowTokens, estimateCtxRatio } from "./chat.js";
40
41
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
41
42
  import { resume } from "./resume.js";
42
- import { collectSessionCards } from "./session-cards.js";
43
+ import { collectSessionCards, projectSessionCard } from "./session-cards.js";
43
44
  // The moved exports stay reachable from this entry — the test imports
44
45
  // (project-trust, coding-agent) never change (B4: zero assertion changes).
45
46
  export { applyProjectMerges } from "./trust-ui.js";
@@ -116,6 +117,22 @@ function readlineInput(rl) {
116
117
  closed: new Promise((resolve) => rl.on("close", () => resolve())),
117
118
  };
118
119
  }
120
+ /* PH-1a (findings PH-F6/PH-F10, the exit-wedge dossier — recorded, NOT
121
+ * fixed here): node keeps TTY fds in blocking mode, macOS pty output
122
+ * buffers are ~1KB, and on an UNREAD terminal a departing process can
123
+ * wedge two ways: (a) process.exit's own flush of pending blocking-TTY
124
+ * writes, and (b) the editor teardown's uv_tty_set_mode → tcsetattr
125
+ * (TCSADRAIN), which waits for that same drain inside an ioctl. Both are
126
+ * pre-existing and both are masked by the DEFAULT SIGTERM/SIGHUP
127
+ * disposition (kernel-level death cuts through a parked loop). Two
128
+ * repairs were built and REVERTED on evidence this round: a JS signal
129
+ * handler (a caught signal needs the loop the wedge just parked — see
130
+ * the tcsetattr ruling at main's handler comment) and a
131
+ * setBlocking(false)-at-exit rule (a non-blocking TTY plus an immediate
132
+ * process.exit DROPS the queued tail — the banner's version line and the
133
+ * dock's CSI r vanished; node made TTYs blocking precisely to prevent
134
+ * that truncation, and that choice is load-bearing). A real fix needs a
135
+ * non-draining native restore path — the PH-F6 mini-spec. */
119
136
  /** The v2c TTY path: the editor's events map 1:1 onto the interface; the
120
137
  * input row renders on every state change (the CLI's onRender wiring). */
121
138
  function editorInput(editor) {
@@ -245,14 +262,32 @@ function extensionsBanner(resume = []) {
245
262
  * first, the CURRENT session excluded (it is not something to pick back
246
263
  * up). Every field already exists behind the store's SessionMeta (the
247
264
  * `kiso sessions` line shows the same data) — only the projection and
248
- * the sort live here. */
249
- function recentSessions(id, agent) {
250
- return agent
265
+ * the sort live here.
266
+ *
267
+ * TT-1B (W5 unification): each row carries the picker's badge glyph,
268
+ * derived through the SAME projection the picker uses (session-cards —
269
+ * one source of truth about durability; a second derivation would be
270
+ * none). Only the 3 chosen sessions are opened — the read-only listing
271
+ * rule holds (projectSessionCard's own guarantee), and the cost is
272
+ * bounded by the list, never the home. */
273
+ async function recentSessions(id, agent) {
274
+ const picked = agent
251
275
  .sessions()
252
276
  .filter((m) => m.id !== id)
253
277
  .sort((a, b) => b.updatedAt - a.updatedAt)
254
- .slice(0, 3)
255
- .map(({ title, events, runs, updatedAt }) => ({ title, events, runs, updatedAt }));
278
+ .slice(0, 3);
279
+ const store = sessionStoreRef;
280
+ const out = [];
281
+ for (const m of picked) {
282
+ let badge;
283
+ if (store !== null) {
284
+ const session = await agent.session({ id: m.id });
285
+ const card = projectSessionCard({ id: m.id, updatedAt: m.updatedAt, records: store.load(m.id), asks: session.pendingApprovals().length });
286
+ badge = BADGE_GLYPH[card.badge];
287
+ }
288
+ out.push({ title: m.title, events: m.events, runs: m.runs, updatedAt: m.updatedAt, ...(badge === undefined ? {} : { badge }) });
289
+ }
290
+ return out;
256
291
  }
257
292
  /**
258
293
  * A area: the coding-agent system prompt — ONE constant, byte-stable for the
@@ -452,6 +487,7 @@ async function makeAgent(sessionId, input, modelFlag) {
452
487
  provider: resolved.profile.kind,
453
488
  apiKey: resolved.apiKey,
454
489
  ...(resolved.profile.baseUrl !== undefined ? { baseUrl: resolved.profile.baseUrl } : {}),
490
+ ...(resolved.profile.promptCaching !== undefined ? { promptCaching: resolved.profile.promptCaching } : {}),
455
491
  }
456
492
  : { adapter: createFauxProvider(readFauxScript().slice(fauxSkipTurns)) }),
457
493
  };
@@ -488,9 +524,12 @@ async function pickSession(agent, input) {
488
524
  }
489
525
  // a dock-less TTY (rows < 4) has no band to draw the picker in, so the
490
526
  // honest answer is the usage line this command has always printed.
527
+ // PH-1a (finding PH-F12): thrown, never process.exit'd from this depth
528
+ // — the old exit(2) skipped main's finally (dock.exit, agent.close,
529
+ // temp cleanup) and could leave the scroll region and lock residue
530
+ // behind. The entry catch translates the error back to exit code 2.
491
531
  if (input.pick === undefined || !dock.active) {
492
- console.error('usage: kiso resume <sessionId> ["prompt"]');
493
- process.exit(2);
532
+ throw new CliUsageError('usage: kiso resume <sessionId> ["prompt"]');
494
533
  }
495
534
  dock.setStatus("", PICKER_HINT);
496
535
  const picked = await new Promise((resolve) => {
@@ -525,6 +564,12 @@ function paintBootStatus(session) {
525
564
  return;
526
565
  dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), agentModel, estimateCtxRatio(session)));
527
566
  }
567
+ /** PH-1a (finding PH-F12): a usage error raised from inside the TUI —
568
+ * main's finally still runs (dock teardown, agent close, temp cleanup)
569
+ * and the entry catch exits with the historical code 2. */
570
+ class CliUsageError extends Error {
571
+ exitCode = 2;
572
+ }
528
573
  async function main() {
529
574
  // E group (the graceful-exit gate ③, R-G 0.1.48): a terminal closing
530
575
  // turns the in-flight stdout/stderr writes into EIO, and node's
@@ -539,6 +584,20 @@ async function main() {
539
584
  // first makeAgent (the tier extensions read `current` live). The flag
540
585
  // is stripped from the positional args, so it works in any position.
541
586
  const args = process.argv.slice(2);
587
+ // PH-1a (finding PH-F2): --help/-h/--version/-v are FLAGS, not session
588
+ // ids. They used to fall through the default case and START A SESSION
589
+ // literally named "--help" (writing ~/.kiso/sessions/--help.jsonl) —
590
+ // the single highest-frequency new-user gesture, failing silently and
591
+ // destructively. Checked FIRST, before any other flag parsing, so
592
+ // `kiso --help` never trips the --model usage error either.
593
+ if (args.some((a) => a === "--help" || a === "-h")) {
594
+ args.length = 0;
595
+ args.push("help");
596
+ }
597
+ else if (args.some((a) => a === "--version" || a === "-v")) {
598
+ console.log(VERSION);
599
+ return;
600
+ }
542
601
  // merge round B: --model <profile|provider/model> — the top of the model
543
602
  // precedence chain; the value flows into makeAgent's config resolution.
544
603
  let modelFlag;
@@ -579,6 +638,21 @@ async function main() {
579
638
  // readline elsewhere. The trust question, chat, and resume all read
580
639
  // through it; main's finally closes it on every exit path.
581
640
  const input = makeLineInput();
641
+ // PH-1a (finding PH-F6, RESOLVED AS WON'T-FIX-IN-JS — the tcsetattr
642
+ // ruling): SIGTERM/SIGHUP deliberately keep their DEFAULT disposition.
643
+ // A JS handler that restored the terminal was built and then reverted
644
+ // on hard evidence: libuv's uv_tty_set_mode calls tcsetattr with
645
+ // TCSADRAIN, which WAITS for the pty's pending output to drain — on an
646
+ // unread terminal (exactly where signals tend to arrive) the editor's
647
+ // teardown parks the event loop in that ioctl forever, and a CAUGHT
648
+ // signal can only be dispatched by the loop it just parked. Catching
649
+ // the signal therefore converts kernel-guaranteed death into a death
650
+ // that may never happen — strictly worse than a dirty terminal. The
651
+ // default disposition keeps SIGTERM/SIGHUP lethal under every state;
652
+ // a signal death leaves raw mode/mouse on and `reset` is the fix (the
653
+ // same contract kill -9 has always had). A safe restore needs a
654
+ // non-draining native path (bytes-only restore, or tcflush-then-set)
655
+ // — its own mini-spec, not a hotfix.
582
656
  // v2d: the body renderer — active only where the dock is (a color
583
657
  // TTY with a real size); pipes run it in passthrough, byte-for-byte.
584
658
  setBody(new Body({
@@ -608,7 +682,7 @@ async function main() {
608
682
  applyConfigMode();
609
683
  const session = await agent.session({ id });
610
684
  bodyLog(`session ${id}\n`);
611
- extensionsBanner(recentSessions(id, agent));
685
+ extensionsBanner(await recentSessions(id, agent));
612
686
  faux = currentFaux;
613
687
  paintBootStatus(session); // TUI2-R2 ⑥: the idle-fresh screen says what it is
614
688
  await chat(session, faux, input, resolveAutoCompact(mergedConfig));
@@ -683,14 +757,29 @@ async function main() {
683
757
  break;
684
758
  }
685
759
  case "help": {
760
+ // PH-1b (finding PH-F21): the CLI must be able to describe its
761
+ // own configuration — the old help listed five commands and
762
+ // stopped, so a new user could never learn from the tool
763
+ // itself how to hand it a key.
686
764
  const p = palette();
687
765
  console.log(`${p.dim}${bannerLines(80, process.stdout.rows ?? 0, VERSION, "").join("\n")}${p.reset}\n\n` +
688
766
  "kiso — the coding agent that survives kill -9\n\n" +
689
767
  " kiso [sessionId] interactive session (default command)\n" +
690
768
  " kiso chat [sessionId] same as above\n" +
769
+ " kiso resume pick a session to continue (TTY picker)\n" +
691
770
  " kiso resume <id> [prompt] continue a session (one-shot)\n" +
692
771
  " kiso sessions list durable sessions\n" +
693
- " kiso help this help\n");
772
+ " kiso help this help\n\n" +
773
+ "flags (any position):\n" +
774
+ " --model <profile|provider/model> pick the model (also /model in-session)\n" +
775
+ " --mode <tier> approval tier: manual|default|accept-edits|plan|bypass\n" +
776
+ " --version print the version\n\n" +
777
+ "configuration:\n" +
778
+ " no key keyless faux demo (a scripted four-round session)\n" +
779
+ " OPENAI_API_KEY OpenAI-compatible (OPENAI_MODEL, default gpt-4o;\n" +
780
+ " OPENAI_BASE_URL for DeepSeek/compat endpoints) — checked first\n" +
781
+ " ANTHROPIC_API_KEY Anthropic (ANTHROPIC_MODEL, default claude-sonnet-5)\n" +
782
+ " ~/.kiso/config.json named model profiles (keys stay in env vars; see the README)\n");
694
783
  break;
695
784
  }
696
785
  case undefined:
@@ -708,7 +797,7 @@ async function main() {
708
797
  agent = await makeAgent(id, input, modelFlag);
709
798
  const session = await agent.session({ id });
710
799
  bodyLog(`session ${id}\n`);
711
- extensionsBanner(recentSessions(id, agent));
800
+ extensionsBanner(await recentSessions(id, agent));
712
801
  // finding E4-1: the same faux resolution the chat/resume cases
713
802
  // carry (faux = currentFaux) — pre-patch the initial faux=true
714
803
  // was passed here, so ANY provider failure in a bare-command
@@ -756,9 +845,31 @@ async function main() {
756
845
  // test imported src/index.js for its functions (harmless before the
757
846
  // first-run scaffold existed; the scaffold WRITES the home). The bin is
758
847
  // a symlink, so argv[1] is realpathed before the comparison.
848
+ /** PH-1a (finding PH-F10): the explicit exit stays (v2a: natural drain is
849
+ * racy on a TTY — readline leaves the stdio handles active), but on a
850
+ * PIPE it now waits for both stdio streams to flush first — process.exit
851
+ * does not drain a pipe's pending async writes, so `kiso sessions | head`
852
+ * could lose its tail. TTY-GATED on purpose: a pipe's flush callbacks
853
+ * always settle (the reader drains, or the break surfaces as EPIPE), but
854
+ * an unread TTY's never do — waiting there would trade a truncation bug
855
+ * for a hang (the exit-wedge dossier above editorInput). The TTY path keeps the
856
+ * v2a immediate exit, byte-for-byte. */
857
+ function exitFlushed(code) {
858
+ if (process.stdout.isTTY === true) {
859
+ process.exit(code);
860
+ }
861
+ let pending = 2;
862
+ const done = () => {
863
+ pending -= 1;
864
+ if (pending === 0)
865
+ process.exit(code);
866
+ };
867
+ process.stdout.write("", done);
868
+ process.stderr.write("", done);
869
+ }
759
870
  if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
760
871
  main()
761
- .then(() => process.exit(0))
872
+ .then(() => exitFlushed(0))
762
873
  .catch((err) => {
763
874
  // round 10: top-level errors are terminal-escaped. v2a: the exit is EXPLICIT
764
875
  // — natural drain is racy on a TTY (readline leaves the stdio handles
@@ -766,6 +877,6 @@ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLTo
766
877
  // ran (agent.close, dispose, temp cleanup) — nothing is skipped, no
767
878
  // lock is left behind; the exit code is honest.
768
879
  console.error(escapeTerminal(err instanceof Error ? err.message : String(err)));
769
- process.exit(1);
880
+ exitFlushed(err instanceof CliUsageError ? err.exitCode : 1);
770
881
  });
771
882
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "kiso CLI — the durable coding agent that survives kill -9: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,19 +18,19 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-ask-ext": "0.13.0",
22
- "@vincemakes/kiso-core": "0.13.0",
23
- "@vincemakes/kiso-evals": "0.13.0",
24
- "@vincemakes/kiso-mcp-ext": "0.13.0",
25
- "@vincemakes/kiso-provider-anthropic": "0.13.0",
26
- "@vincemakes/kiso-provider-openai": "0.13.0",
27
- "@vincemakes/kiso-runtime": "0.13.0",
28
- "@vincemakes/kiso-skills-ext": "0.13.0",
29
- "@vincemakes/kiso-subagent-ext": "0.13.0",
30
- "@vincemakes/kiso-task-ext": "0.13.0",
31
- "@vincemakes/kiso-tools-node": "0.13.0",
32
- "@vincemakes/kiso-tui": "0.13.0",
33
- "@vincemakes/kiso-tui-cells": "0.13.0"
21
+ "@vincemakes/kiso-ask-ext": "0.15.0",
22
+ "@vincemakes/kiso-core": "0.15.0",
23
+ "@vincemakes/kiso-evals": "0.15.0",
24
+ "@vincemakes/kiso-mcp-ext": "0.15.0",
25
+ "@vincemakes/kiso-provider-anthropic": "0.15.0",
26
+ "@vincemakes/kiso-provider-openai": "0.15.0",
27
+ "@vincemakes/kiso-runtime": "0.15.0",
28
+ "@vincemakes/kiso-skills-ext": "0.15.0",
29
+ "@vincemakes/kiso-subagent-ext": "0.15.0",
30
+ "@vincemakes/kiso-task-ext": "0.15.0",
31
+ "@vincemakes/kiso-tools-node": "0.15.0",
32
+ "@vincemakes/kiso-tui": "0.15.0",
33
+ "@vincemakes/kiso-tui-cells": "0.15.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^26.1.2",
@@ -40,6 +40,10 @@
40
40
  "engines": {
41
41
  "node": ">=22"
42
42
  },
43
+ "os": [
44
+ "darwin",
45
+ "linux"
46
+ ],
43
47
  "repository": {
44
48
  "type": "git",
45
49
  "url": "https://github.com/vincemakes/kiso.git",