@vincemakes/kiso-code 0.7.0 → 0.9.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/builtin.d.ts CHANGED
@@ -1,2 +1,8 @@
1
+ import { type AskUI } from "@vincemakes/kiso-ask-ext";
1
2
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
2
- export declare function builtInLayer(user: readonly KisoExtension[], project: readonly KisoExtension[]): Promise<readonly KisoExtension[]>;
3
+ export declare function builtInLayer(user: readonly KisoExtension[], project: readonly KisoExtension[],
4
+ /** KC3.5: built-in #4 — the ask extension, loaded ONLY when a panel
5
+ * bridge exists (an interactive TTY). No bridge, no fourth built-in:
6
+ * a headless session never pays the rent for a question nobody could
7
+ * answer, and its tool table cannot mention ask_user. */
8
+ ask?: AskUI): Promise<readonly KisoExtension[]>;
package/dist/builtin.js CHANGED
@@ -24,8 +24,14 @@
24
24
  import createMcp from "@vincemakes/kiso-mcp-ext";
25
25
  import createSkills from "@vincemakes/kiso-skills-ext";
26
26
  import createSubagent from "@vincemakes/kiso-subagent-ext";
27
- export async function builtInLayer(user, project) {
28
- const all = await Promise.all([createMcp(), createSkills(), createSubagent()]);
27
+ import createAsk, {} from "@vincemakes/kiso-ask-ext";
28
+ export async function builtInLayer(user, project,
29
+ /** KC3.5: built-in #4 — the ask extension, loaded ONLY when a panel
30
+ * bridge exists (an interactive TTY). No bridge, no fourth built-in:
31
+ * a headless session never pays the rent for a question nobody could
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)])]);
29
35
  const shadowed = all.filter((b) => user.some((u) => u.name === b.name));
30
36
  for (const s of shadowed) {
31
37
  console.error(`[extensions] user extension "${s.name}" shadows the built-in — the built-in is not loaded`);
package/dist/chat.d.ts CHANGED
@@ -45,6 +45,10 @@ export interface UsageDelta {
45
45
  * recovers on the next known event). */
46
46
  readonly total: number | null;
47
47
  readonly missed: number | null;
48
+ /** TUI2-R1 (E): the CANONICAL cost of this request — null when the
49
+ * pricing table has no rate for the route (the R5b-④c absent stamp).
50
+ * Null is carried, never zeroed: a missing rate is not a free call. */
51
+ readonly costUsd: number | null;
48
52
  }
49
53
  /**
50
54
  * The CLI's usage consumer (E2 1.3.0, the R2a-1 ruling 2026-08-13) — the
@@ -76,6 +80,7 @@ export declare function usageFromEvent(route: string | undefined, ev: import("@v
76
80
  * is gone) — docked only, 200ms rotation between the request and the
77
81
  * first event. */
78
82
  export declare function startStatusSpinner(onTick: (glyph: string) => void): () => void;
83
+ export declare function startShellTail(sessionId: string, callId: string, command: string, startedAt: number): () => void;
79
84
  /**
80
85
  * Consume a run, answering approval pauses as they arrive. `resumeMode`
81
86
  * marks a session.resume() continuation. `faux` picks the status line's
@@ -84,7 +89,7 @@ export declare function startStatusSpinner(onTick: (glyph: string) => void): ()
84
89
  * echo is UI, the chip is the record; the momentary double-render is
85
90
  * the design's explicit point).
86
91
  */
87
- export declare function consumeRun(session: AgentSession, run: Run, input: LineInput, turnNo: number, faux: boolean, statusCb: ((usage: RunUsage, ctxRatio: number) => void) | null,
92
+ export declare function consumeRun(session: AgentSession, run: Run, input: LineInput, turnNo: number, faux: boolean, statusCb: ((usage: RunUsage, ctxRatio: number, costUsd?: number | null) => void) | null,
88
93
  /** W21: the amend words ("Yes + feedback") ride the NEXT user turn —
89
94
  * threaded from chat's submitTurn; absent in the recovery flow
90
95
  * (resume) where a dropped amend is noticed instead. */
package/dist/chat.js CHANGED
@@ -4,10 +4,10 @@
4
4
  * approval-moment mini-diff, the status spinner, and the context
5
5
  * estimates. All bodies moved verbatim from index.ts.
6
6
  */
7
- import { readFileSync } from "node:fs";
8
- import { escapeTerminal, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
7
+ import { readFileSync, statSync } from "node:fs";
8
+ import { escapeTerminal, cacheHitPct, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
9
9
  import { editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
- import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
10
+ import { canonicalTargetPath, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
11
  import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
12
12
  import { dispatch } from "./dispatch.js";
13
13
  import { agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
@@ -89,7 +89,7 @@ export function usageFromEvent(route, ev, prevTotal) {
89
89
  const m = Math.min(prevTotal, total) - c.cacheRead;
90
90
  missed = m > CACHE_MISS_FLOOR ? m : null;
91
91
  }
92
- return { usage: { in: c.input, out: c.output, cache: c.cacheRead, known: ev.known }, total, missed };
92
+ return { usage: { in: c.input, out: c.output, cache: c.cacheRead, known: ev.known }, total, missed, costUsd: c.costUsd };
93
93
  }
94
94
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
95
95
  * is gone) — docked only, 200ms rotation between the request and the
@@ -105,6 +105,45 @@ export function startStatusSpinner(onTick) {
105
105
  timer.unref();
106
106
  return () => clearInterval(timer);
107
107
  }
108
+ /**
109
+ * TUI2-R1 (C) — the shell tailer: the READER half of the progress
110
+ * sidecar (the writer is the shell tool, tools-node).
111
+ *
112
+ * The CLI is the only place that holds both facts the derived key needs
113
+ * — the session's id and the running call's command — so the tail is
114
+ * read here and handed to the cell. A poll, not a watcher: fs.watch's
115
+ * behaviour on a file being appended to differs by platform, and the
116
+ * one thing this must never do is misbehave in a way that costs the run.
117
+ *
118
+ * THE FRESHNESS GUARD is the part that makes a kill -9 leftover
119
+ * harmless. A sidecar the writer never got to remove keeps its old
120
+ * mtime; a tail is read only from a file modified AT OR AFTER the call
121
+ * started. A ghost from a previous process cannot be shown as this
122
+ * call's output — and since the tail is display-only, showing nothing is
123
+ * always the safe answer.
124
+ */
125
+ const TAIL_POLL_MS = 250;
126
+ const TAIL_BYTES = 4096; // the last lines are all the window can hold
127
+ export function startShellTail(sessionId, callId, command, startedAt) {
128
+ const path = shellProgressPath(sessionId, command);
129
+ const read = () => {
130
+ try {
131
+ const stat = statSync(path);
132
+ if (stat.mtimeMs + 1000 < startedAt)
133
+ return; // a ghost from a killed run — never this call's
134
+ const text = readFileSync(path, "utf8");
135
+ body.toolProgress(callId, text.slice(-TAIL_BYTES).trimEnd());
136
+ }
137
+ catch {
138
+ // no sidecar yet, removed at settle, or unreadable — the tail
139
+ // is an observation, and its absence is never an error
140
+ }
141
+ };
142
+ read();
143
+ const timer = setInterval(read, TAIL_POLL_MS);
144
+ timer.unref();
145
+ return () => clearInterval(timer);
146
+ }
108
147
  /** v2e: the approval-moment mini-diff — edit_file/write_file changes as
109
148
  * ± lines; other tools get null (no diff, no cost). The file read is
110
149
  * best-effort: an unreadable file yields NO diff, never a failure —
@@ -128,7 +167,9 @@ function approvalDiff(name, input) {
128
167
  const replace = typeof input.replace === "string" ? input.replace : "";
129
168
  if (search === "")
130
169
  return null;
131
- return editFileDiff(oldContent ?? "", search, replace);
170
+ // TUI2-R1.5 ② (VD-2): the path rides along so a miss can name the
171
+ // file in its honest note instead of fabricating a diff.
172
+ return editFileDiff(oldContent ?? "", search, replace, path);
132
173
  }
133
174
  const content = typeof input.content === "string" ? input.content : "";
134
175
  return writeFileDiff(oldContent, content);
@@ -268,6 +309,16 @@ submitTurn) {
268
309
  // at the first non-thinking event (the fold needs the seconds).
269
310
  let thoughtSeconds = 0;
270
311
  let thinkingSince = null;
312
+ // TUI2-R1 (C): the shell commands seen this run, and the tailers
313
+ // running for them. A tailer is started when the execution starts and
314
+ // stopped at the call's result — and the finally below stops any that
315
+ // an abort left behind, so a poller can never outlive its run.
316
+ const shellCommands = new Map();
317
+ const tailers = new Map();
318
+ const stopTail = (callId) => {
319
+ tailers.get(callId)?.();
320
+ tailers.delete(callId);
321
+ };
271
322
  try {
272
323
  for await (const ev of run) {
273
324
  last = ev;
@@ -295,10 +346,18 @@ submitTurn) {
295
346
  if (ev.name === "edit_file")
296
347
  editCount += 1;
297
348
  body.toolStart(ev.name, ev.callId, ev.input ?? {});
349
+ // TUI2-R1 (C): the command is the sidecar key's other half —
350
+ // remembered here, used when the execution actually starts.
351
+ if (ev.name === "shell" && typeof ev.input?.command === "string")
352
+ shellCommands.set(ev.callId, ev.input.command);
298
353
  break;
299
- case "tool_execution_started":
354
+ case "tool_execution_started": {
300
355
  body.toolRunning(ev.callId);
356
+ const command = shellCommands.get(ev.callId);
357
+ if (command !== undefined)
358
+ tailers.set(ev.callId, startShellTail(session.id, ev.callId, command, Date.now()));
301
359
  break;
360
+ }
302
361
  case "tool_execution_succeeded":
303
362
  body.toolSucceeded(ev.callId);
304
363
  break;
@@ -306,6 +365,9 @@ submitTurn) {
306
365
  body.toolFailed(ev.callId, ev.error);
307
366
  break;
308
367
  case "tool_result": {
368
+ // TUI2-R1 (C): the observation window closes the instant the
369
+ // real result exists — the tail must never race it.
370
+ stopTail(ev.callId);
309
371
  const text = typeof ev.content === "string" ? ev.content : "";
310
372
  // W19: a DENIED call carries its reason — extracted from the
311
373
  // result's "[Permission denied] " prefix, keyed on the
@@ -340,7 +402,9 @@ submitTurn) {
340
402
  usage = delta.usage;
341
403
  prevTotal = delta.total;
342
404
  missed = delta.missed;
343
- statusCb?.(usage, estimateCtxRatio(session));
405
+ // TUI2-R1 (E): the request's canonical cost rides the same
406
+ // callback the usage does — one settled request, one addition.
407
+ statusCb?.(usage, estimateCtxRatio(session), delta.costUsd);
344
408
  break;
345
409
  }
346
410
  case "uncertain_pending":
@@ -471,6 +535,11 @@ submitTurn) {
471
535
  body.thinkingEnd(); // a trailing thinking block folds at the run's end
472
536
  }
473
537
  finally {
538
+ // TUI2-R1 (C): an abort or a throw leaves the loop without a
539
+ // tool_result — every tailer stops here regardless, so no poller
540
+ // outlives the run that started it.
541
+ for (const callId of [...tailers.keys()])
542
+ stopTail(callId);
474
543
  }
475
544
  return last;
476
545
  }
@@ -639,6 +708,11 @@ export async function chat(session, faux, input, autoCompact) {
639
708
  // glyph (▖▘▝▗ — the spinner drives it) + wall seconds + ↓ out tokens
640
709
  // + the interrupt hint. ctx left is the live estimate everywhere.
641
710
  let runUsage = { in: null, out: null, cache: null, known: false };
711
+ // TUI2-R1 (E): the session's spend so far — the CANONICAL cost of every
712
+ // request this process has seen, summed. Null stays null: a route with
713
+ // no rate in the pricing table contributes nothing and the row shows no
714
+ // $ at all, because a partial total presented as a total is a lie.
715
+ let spentUsd = null;
642
716
  let runGlyph = "▖";
643
717
  let runStart = Date.now();
644
718
  // KC2 §5: the STATE (the glyph, the run's start, the usage, the dock)
@@ -651,13 +725,29 @@ export async function chat(session, faux, input, autoCompact) {
651
725
  // parentheses idiom names the read-only constraint. The tier is the
652
726
  // CALLER's word (the recovery flow passes the bare mode).
653
727
  const paintIdle = () => {
654
- if (dock.active)
655
- dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), agentModel, estimateCtxRatio(session)));
728
+ if (!dock.active)
729
+ return;
730
+ // TUI2-R1 (E): the meter rides the idle row — both fields omitted
731
+ // when unknown, so a session that has not called the model paints
732
+ // exactly the pre-round row.
733
+ dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), agentModel, estimateCtxRatio(session), {
734
+ cacheHitPct: cacheHitPct(runUsage),
735
+ costUsd: spentUsd,
736
+ }));
656
737
  };
657
- const statusCb = (u, ctx) => {
738
+ const statusCb = (u, ctx, costUsd) => {
658
739
  runUsage = u;
740
+ addCost(costUsd ?? null);
659
741
  paintRunning();
660
742
  };
743
+ // TUI2-R1 (E): the canonical cost of one settled request, added to the
744
+ // session's running total. A null cost (no rate for the route) adds
745
+ // nothing and leaves the total as it was.
746
+ const addCost = (usd) => {
747
+ if (usd === null)
748
+ return;
749
+ spentUsd = (spentUsd ?? 0) + usd;
750
+ };
661
751
  const submitTurn = (line) => {
662
752
  const slot = { line, cancelled: false };
663
753
  pendingTurns.push(slot);
@@ -717,6 +807,7 @@ export async function chat(session, faux, input, autoCompact) {
717
807
  paintIdle,
718
808
  submitTurn,
719
809
  estimateCtx: () => estimateCtxRatio(session),
810
+ contextWindow: () => contextWindowTokens(),
720
811
  };
721
812
  // the ergonomics batch C8: the auto-compact check — the /compact FULL path via the
722
813
  // shared dispatch (same notices, same chain ordering, same mid-run
@@ -756,6 +847,15 @@ export async function chat(session, faux, input, autoCompact) {
756
847
  const recoveryRun = session.resume();
757
848
  currentRun = recoveryRun;
758
849
  turnNo += 1;
850
+ // TUI2-R1.5 ③ (VD-3 family): stamp the run's start AT the run's
851
+ // entry. Every other run path does; this one inherited the value
852
+ // from the process's own startup, so its "working Ns" was the
853
+ // session's age rather than the recovery's. The drift is small
854
+ // today (recovery follows startup closely) and unbounded in
855
+ // principle — a slow MCP connect is seconds the recovery never
856
+ // spent, reported as seconds it did.
857
+ runStart = Date.now();
858
+ runUsage = { in: null, out: null, cache: null, known: false };
759
859
  const last = await consumeRun(session, recoveryRun, input, turnNo, faux, statusCb, submitTurn);
760
860
  currentRun = null;
761
861
  failOnFauxExhaustion(last, faux, input);
@@ -22,6 +22,9 @@ export interface DispatchCtx {
22
22
  readonly submitTurn: (line: string) => void;
23
23
  /** the /status context estimate. */
24
24
  readonly estimateCtx: () => number;
25
+ /** TUI2-R1 (E): the model's context window, as the session is
26
+ * configured — the /context ledger's denominator. */
27
+ readonly contextWindow: () => number;
25
28
  }
26
29
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
27
30
  * replay routes through it too — a queued "/last" must never become a
package/dist/dispatch.js CHANGED
@@ -3,10 +3,10 @@
3
3
  * turns. The bodies moved verbatim from chat()'s closure; chat provides
4
4
  * the context (the chain, the run state, the prompt arming).
5
5
  */
6
- import { escapeTerminal, kUnit, palette } from "@vincemakes/kiso-tui";
6
+ import { contextRows, contextUnavailableRows, escapeTerminal, helpRows, kUnit, palette } from "@vincemakes/kiso-tui";
7
7
  import { buildAdapter } from "@vincemakes/kiso-runtime/internal";
8
8
  import { MODES, getMode, setMode } from "./mode.js";
9
- import { agentModel, body, bodyLog, configModels, dock, setAgentModel, setCurrentModelName } from "./state.js";
9
+ import { agentModel, body, bodyLog, configModels, dock, readContextLedger, setAgentModel, setCurrentModelName } from "./state.js";
10
10
  import { directWriteProfile, profileAvailable } from "./config.js";
11
11
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
12
12
  * replay routes through it too — a queued "/last" must never become a
@@ -15,24 +15,18 @@ import { directWriteProfile, profileAvailable } from "./config.js";
15
15
  export function dispatch(line, ctx) {
16
16
  const trimmed = line.trim();
17
17
  if (trimmed === "/help") {
18
- // Prints the available commands with one-line descriptions.
19
- // v2a: the command names are the blue identity accent.
20
- const p = palette();
21
- const cmd = (name, desc) => `${p.bold}${name}${p.reset} ${desc}`;
18
+ // KC3.5 slice ⓪ (the extraction): the ROWS moved to the terminal
19
+ // layer's strings module (helpRows the KC3 §1 pattern: what the
20
+ // human reads is presentation). The FLOW is what stays here, and
21
+ // it is unchanged: print on the chain, then re-prompt. The rows
22
+ // are byte-identical to the eight bodyLog calls they replace —
23
+ // the last one still carries its own \n, so `exit` and `keys`
24
+ // land as two rows from one call (bodyLog splits on \n).
22
25
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
23
- bodyLog(cmd("/help", "print this list of commands"));
24
- bodyLog(cmd("/think", "show the last full thinking block"));
25
- bodyLog(cmd("/last", "show the most recent tool call's input and output"));
26
- bodyLog(cmd("/status", "show session id, event count, and context estimate"));
27
- bodyLog(cmd("/mode", "show the approval tier; /mode <name> switches (manual/default/accept-edits/plan/bypass)"));
28
- bodyLog(cmd("/model", "list model profiles; /model <name|provider/model> switches"));
29
- bodyLog(cmd("/compact", "summarize the older conversation to free context"));
30
- // KC1: the composer's keys ride the SAME bodyLog call (it splits
31
- // on \n) — the help gains a row, the cli source does not.
32
- // KC2: the redirect joins the same row for the same reason.
33
- // KC3: and so does the @ picker — the row is where a gesture is
34
- // taught, and the row costs nothing.
35
- bodyLog(`${cmd("exit", "leave the session")}\n${cmd("keys", "enter sends · ctrl+J newline (shift+enter where encoded) · esc stops the run · alt+⏎ stops it and sends this instead · @ files")}`);
26
+ // TUI2-R1.5 9 (VD-10): /help is sentences for a human — the keys
27
+ // row in particular is one long line that hard-folded mid-word.
28
+ for (const row of helpRows())
29
+ bodyLog(row, "words");
36
30
  ctx.input.prompt();
37
31
  });
38
32
  return;
@@ -100,6 +94,30 @@ export function dispatch(line, ctx) {
100
94
  ctx.chainRef.current = ctx.chainRef.current.then(land);
101
95
  return;
102
96
  }
97
+ if (trimmed === "/context") {
98
+ // TUI2-R1 (E): the rent-ledger attribution — where the context went,
99
+ // read from the session's TRACE SIDECAR (the observation file E1/E3
100
+ // already write, per request).
101
+ //
102
+ // THE PURITY GATE IS UNTOUCHED and this is why: the trace surface is
103
+ // an OBSERVATION surface (ADR-0051 §6, ruling R7) and correctness
104
+ // never reads it. /context is a DISPLAY command — nothing it reads
105
+ // reaches a recovery plan, a projection, or a request. The read is
106
+ // best-effort by construction: a missing, partial or unparseable
107
+ // ledger renders the honest fallback, never an error and never a
108
+ // guess. recovery-purity.test.ts's probes are unaffected: the
109
+ // derivation still does zero I/O and still ignores trace-shaped data.
110
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
111
+ const ledger = readContextLedger(ctx.session.id, ctx.contextWindow());
112
+ for (const row of ledger === null
113
+ ? contextUnavailableRows("the ledger is written per request — run a turn, then ask again")
114
+ : contextRows(ledger)) {
115
+ bodyLog(row);
116
+ }
117
+ ctx.input.prompt();
118
+ });
119
+ return;
120
+ }
103
121
  if (trimmed === "/status") {
104
122
  // B area: session id, durable event count, and the ~ context
105
123
  // estimate — all read straight from the live session, nothing
package/dist/index.js CHANGED
@@ -26,14 +26,14 @@ import { readFileSync, realpathSync, rmSync } from "node:fs";
26
26
  import { createInterface } from "node:readline";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { join } from "node:path";
29
- import { Body, Editor, bannerLines, escapeTerminal, interactivePrompt, palette, renderSessionLine } from "@vincemakes/kiso-tui";
29
+ import { Body, Editor, bannerLines, escapeTerminal, extensionsBannerText, interactivePrompt, palette, renderSessionLine } from "@vincemakes/kiso-tui";
30
30
  import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, SessionStore, } from "@vincemakes/kiso-runtime";
31
31
  import { createFauxProvider } from "@vincemakes/kiso-evals";
32
32
  import { createCodingTools } from "@vincemakes/kiso-tools-node";
33
33
  import { MODES, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
34
34
  import { builtInLayer } from "./builtin.js";
35
35
  import { atFiles, body, bodyLog, builtInExtensions, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, projectExtensions, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, userExtensions, VERSION } from "./state.js";
36
- import { resolveProjectTrust } from "./trust-ui.js";
36
+ import { askUi, resolveProjectTrust } from "./trust-ui.js";
37
37
  import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
38
38
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
39
39
  import { autoCompactFromEnv, chat, contextWindowTokens } from "./chat.js";
@@ -199,6 +199,7 @@ function makeLineInput() {
199
199
  editor.bindAtItems(atFiles); // KC3 §5: the file source — listed per OPEN
200
200
  dock.bindAt(() => editor.atState()); // KC3 §4: the picker's band
201
201
  dock.bindApproval(() => editor.panelState()); // W21: the panel's bound state
202
+ dock.bindSheet(() => editor.sheetOpen()); // TUI2-R1 (D): the ? keys sheet
202
203
  return editorInput(editor);
203
204
  }
204
205
  return readlineInput(createInterface({ input: process.stdin, output: process.stdout }));
@@ -209,20 +210,11 @@ function makeLineInput() {
209
210
  * a fresh install reads `[3 extensions: built-in: mcp, skills, subagent]`
210
211
  * with zero disk setup (E5: the task extension is opt-in). */
211
212
  function bannerExtensionText() {
212
- const total = builtInExtensions.length + userExtensions.length + projectExtensions.length;
213
- if (total === 0)
214
- return "";
215
- const parts = [];
216
- // 0.1.26 (MCP lazy connection): an extension with a live `connecting` flag shows
217
- // its in-flight state in the banner — "mcp (connecting…)".
218
- const label = (e) => e.connecting === true ? `${e.name} (connecting…)` : e.name;
219
- if (builtInExtensions.length > 0)
220
- parts.push(`built-in: ${builtInExtensions.map(label).join(", ")}`);
221
- if (userExtensions.length > 0)
222
- parts.push(userExtensions.map(label).join(", "));
223
- if (projectExtensions.length > 0)
224
- parts.push(`project: ${projectExtensions.map(label).join(", ")}`);
225
- return ` · [${total} extension${total === 1 ? "" : "s"}: ${parts.join(" · ")}]`;
213
+ // KC3.5 slice (the extraction): the composition moved to the
214
+ // terminal layer (extensionsBannerText a pure function of the three
215
+ // name lists, including the "(connecting…)" in-flight label). Which
216
+ // lists exist is the CLI's fact and stays here.
217
+ return extensionsBannerText(builtInExtensions, userExtensions, projectExtensions);
226
218
  }
227
219
  /** E1: the startup banner line(s) — TTY: logo + merged extensions + the
228
220
  * W5 resume list as a LIVE banner cell (W1: the tier re-derives on
@@ -374,7 +366,11 @@ async function makeAgent(sessionId, input, modelFlag) {
374
366
  const proj = project !== null ? await loadProjectExtensions(process.cwd(), user) : [];
375
367
  // R-D 0.1.45: the built-in layer registers by module import (builtin.ts)
376
368
  // — a user extension may shadow a built-in, a project one may not.
377
- const builtIn = await builtInLayer(user, proj);
369
+ // KC3.5: built-in #4 (ask) registers ONLY where a human can answer —
370
+ // the panel bridge is the argument, and a non-TTY session has none to
371
+ // give. A piped run's composed tool table therefore cannot contain
372
+ // ask_user (T-Q3: the bench's structural byte-identity proof).
373
+ const builtIn = await builtInLayer(user, proj, input !== undefined && process.stdin.isTTY ? askUi(input) : undefined);
378
374
  setExtensionLists(builtIn, user, proj, [...builtIn, ...user, ...proj]);
379
375
  // merge round B — the config surface: user config + (trusted) project config,
380
376
  // resolved with flags > env > project > user > default. The CLI never
package/dist/state.d.ts CHANGED
@@ -15,6 +15,22 @@ export declare function kisoHome(): string;
15
15
  export declare function sessionsDir(): string;
16
16
  /** E1: the extension scan directory — KISO_EXTENSIONS_DIR overrides. */
17
17
  export declare function extensionsDir(): string;
18
+ /**
19
+ * TUI2-R1 (E) — the /context ledger, read from the session's TRACE
20
+ * SIDECAR (<sessions>/traces/<id>.jsonl).
21
+ *
22
+ * THE PURITY GATE (ADR-0051 §6, ruling R7) IS UNTOUCHED. The trace
23
+ * surface is an observation surface; correctness never reads it, and
24
+ * this reader is on the DISPLAY path only — nothing it returns reaches a
25
+ * recovery plan, a projection or a request. The proof of that is the
26
+ * shape of this function: it is best-effort end to end, and its failure
27
+ * mode is `null`, which renders a sentence rather than a number.
28
+ *
29
+ * The LAST request line is the one that matters: rent is per request,
30
+ * and what the reader wants to know is what the NEXT request will cost,
31
+ * which is what the previous one cost.
32
+ */
33
+ export declare function readContextLedger(sessionId: string, window: number): import("@vincemakes/kiso-tui").ContextLedger | null;
18
34
  /**
19
35
  * KC3 §5 — the @ picker's file source. Computed PER OPEN: no index, no
20
36
  * daemon, no watcher, nothing to invalidate and nothing to go stale.
@@ -90,7 +106,7 @@ export declare let body: Body;
90
106
  export declare function setBody(value: Body): void;
91
107
  /** v2d: body output routes through the cell renderer — the single writer.
92
108
  * bodyLog adds the trailing newline; internal newlines are preserved. */
93
- export declare function bodyLog(text: string): void;
109
+ export declare function bodyLog(text: string, wrap?: "words"): void;
94
110
  /** The model name for the status bar — set by makeAgent. */
95
111
  export declare let agentModel: string;
96
112
  export declare function setAgentModel(value: string): void;
package/dist/state.js CHANGED
@@ -25,6 +25,71 @@ export function sessionsDir() {
25
25
  export function extensionsDir() {
26
26
  return process.env.KISO_EXTENSIONS_DIR ?? join(kisoHome(), "extensions");
27
27
  }
28
+ /**
29
+ * TUI2-R1 (E) — the /context ledger, read from the session's TRACE
30
+ * SIDECAR (<sessions>/traces/<id>.jsonl).
31
+ *
32
+ * THE PURITY GATE (ADR-0051 §6, ruling R7) IS UNTOUCHED. The trace
33
+ * surface is an observation surface; correctness never reads it, and
34
+ * this reader is on the DISPLAY path only — nothing it returns reaches a
35
+ * recovery plan, a projection or a request. The proof of that is the
36
+ * shape of this function: it is best-effort end to end, and its failure
37
+ * mode is `null`, which renders a sentence rather than a number.
38
+ *
39
+ * The LAST request line is the one that matters: rent is per request,
40
+ * and what the reader wants to know is what the NEXT request will cost,
41
+ * which is what the previous one cost.
42
+ */
43
+ export function readContextLedger(sessionId, window) {
44
+ let last = null;
45
+ try {
46
+ const text = readFileSync(join(sessionsDir(), "traces", `${sessionId}.jsonl`), "utf8");
47
+ for (const line of text.split("\n")) {
48
+ if (line === "")
49
+ continue;
50
+ try {
51
+ const parsed = JSON.parse(line);
52
+ if (parsed.kind === "request")
53
+ last = parsed;
54
+ }
55
+ catch {
56
+ // a torn last line (the writer was mid-append) — the ledger is
57
+ // an observation, and a partial one is simply not the answer
58
+ }
59
+ }
60
+ }
61
+ catch {
62
+ return null; // no sidecar — a session that has not called the model
63
+ }
64
+ if (last === null)
65
+ return null;
66
+ const rent = Array.isArray(last.rent) ? last.rent : [];
67
+ if (rent.length === 0)
68
+ return null; // a v1/v2 sidecar carries no rent block (R2-1)
69
+ const sum = (pred) => rent.filter((l) => typeof l.surface === "string" && pred(l.surface)).reduce((a, l) => a + (l.estTokens ?? 0), 0);
70
+ const count = (pred) => rent.filter((l) => typeof l.surface === "string" && pred(l.surface)).length;
71
+ // the skills index is broken out: it is an INDEX of workspace content,
72
+ // not an instruction, and it is the one append whose size is the
73
+ // reader's own doing.
74
+ const isSkills = (s) => s === "system:ext:skills";
75
+ const manifest = Array.isArray(last.contextManifest) ? last.contextManifest : [];
76
+ const turnSegments = manifest.filter((s) => s.role === "turn" || s.role === "current_turn");
77
+ return {
78
+ window,
79
+ systemPrompt: sum((s) => s === "system:base" || (s.startsWith("system:ext:") && !isSkills(s))),
80
+ systemBase: sum((s) => s === "system:base"),
81
+ appends: count((s) => s.startsWith("system:ext:") && !isSkills(s)),
82
+ toolTable: sum((s) => s.startsWith("tool:")),
83
+ tools: count((s) => s.startsWith("tool:")),
84
+ skillsIndex: sum(isSkills),
85
+ // the ledger records the SURFACE, never its contents — the number
86
+ // of skills is not in it, and 0 tells the renderer to say so.
87
+ skills: 0,
88
+ envelope: sum((s) => s === "envelope"),
89
+ messages: turnSegments.reduce((a, s) => a + (s.estTokens ?? 0), 0),
90
+ turns: turnSegments.length,
91
+ };
92
+ }
28
93
  /**
29
94
  * KC3 §5 — the @ picker's file source. Computed PER OPEN: no index, no
30
95
  * daemon, no watcher, nothing to invalidate and nothing to go stale.
@@ -95,8 +160,8 @@ export function setBody(value) {
95
160
  }
96
161
  /** v2d: body output routes through the cell renderer — the single writer.
97
162
  * bodyLog adds the trailing newline; internal newlines are preserved. */
98
- export function bodyLog(text) {
99
- body.raw(text.split("\n"));
163
+ export function bodyLog(text, wrap) {
164
+ body.raw(text.split("\n"), wrap);
100
165
  }
101
166
  /** The model name for the status bar — set by makeAgent. */
102
167
  export let agentModel = "faux";
@@ -6,6 +6,7 @@
6
6
  * decisions. All bodies moved verbatim from index.ts.
7
7
  */
8
8
  import { type PanelVerdict, type PanelView } from "@vincemakes/kiso-tui";
9
+ import type { AskUI } from "@vincemakes/kiso-ask-ext";
9
10
  import { type ProjectArtifacts } from "@vincemakes/kiso-runtime";
10
11
  import type { AgentSession } from "@vincemakes/kiso-runtime";
11
12
  import { type LineInput } from "./state.js";
@@ -34,6 +35,24 @@ import { type LineInput } from "./state.js";
34
35
  */
35
36
  export declare let pendingAsk: (() => void) | null;
36
37
  export declare function askPanel(input: LineInput, view: PanelView): Promise<PanelVerdict>;
38
+ /**
39
+ * KC3.5 — the AskUI bridge: the panel the ask extension asks through.
40
+ *
41
+ * It is deliberately three lines of GLUE. Everything an ask needs was
42
+ * already built for W21 and is reused whole: askPanel owns the
43
+ * abortable ask (the SIGINT/esc path resolves it), the non-interactive
44
+ * refusal (printed loudly, never hung) and the dock-less fallback; the
45
+ * editor owns the keys and the buffer stash; the tui owns the rows and
46
+ * the walk. What is left here is the mapping — the panel's `answers`
47
+ * verdict IS the tool's result, and EVERY other verdict is the decline,
48
+ * which is an honest recorded outcome naming what went unanswered.
49
+ *
50
+ * (A cancel, a non-interactive deny and a dock-less y/n all land in the
51
+ * same place on purpose: none of them is an answer to a multiple-choice
52
+ * question, and inventing one would be the dishonesty the whole round
53
+ * is built to avoid.)
54
+ */
55
+ export declare function askUi(input: LineInput): AskUI;
37
56
  /**
38
57
  * W21/R3 — the "2 Yes, don't ask again for <tool>" rule: a GENERATED
39
58
  * extension, ALLOW-ONLY by construction (it never emits deny or ask —
package/dist/trust-ui.js CHANGED
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkS
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
- import { projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView } from "@vincemakes/kiso-tui";
12
+ import { askDeclineAll, askView, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, unansweredAskView } from "@vincemakes/kiso-tui";
13
13
  import { projectArtifacts, recordTrust, trustFor } from "@vincemakes/kiso-runtime";
14
14
  import { bodyLog, currentAgentExtensions, dock, extensionsDir, kisoHome, mergedTempPaths } from "./state.js";
15
15
  import { loadUserConfig, resolveProjectTrustPolicy } from "./config.js";
@@ -83,6 +83,31 @@ export function askPanel(input, view) {
83
83
  }
84
84
  });
85
85
  }
86
+ /**
87
+ * KC3.5 — the AskUI bridge: the panel the ask extension asks through.
88
+ *
89
+ * It is deliberately three lines of GLUE. Everything an ask needs was
90
+ * already built for W21 and is reused whole: askPanel owns the
91
+ * abortable ask (the SIGINT/esc path resolves it), the non-interactive
92
+ * refusal (printed loudly, never hung) and the dock-less fallback; the
93
+ * editor owns the keys and the buffer stash; the tui owns the rows and
94
+ * the walk. What is left here is the mapping — the panel's `answers`
95
+ * verdict IS the tool's result, and EVERY other verdict is the decline,
96
+ * which is an honest recorded outcome naming what went unanswered.
97
+ *
98
+ * (A cancel, a non-interactive deny and a dock-less y/n all land in the
99
+ * same place on purpose: none of them is an answer to a multiple-choice
100
+ * question, and inventing one would be the dishonesty the whole round
101
+ * is built to avoid.)
102
+ */
103
+ export function askUi(input) {
104
+ return {
105
+ ask: async (spec) => {
106
+ const verdict = await askPanel(input, askView(spec));
107
+ return verdict.action === "answers" ? verdict.result : askDeclineAll(spec);
108
+ },
109
+ };
110
+ }
86
111
  /**
87
112
  * W21/R3 — the "2 Yes, don't ask again for <tool>" rule: a GENERATED
88
113
  * extension, ALLOW-ONLY by construction (it never emits deny or ask —
@@ -299,7 +324,12 @@ function readdirSyncSafe(dir) {
299
324
  * the body is the single stdout writer, never a stray console.log. */
300
325
  export async function resolveUncertains(session, input, isCancelled) {
301
326
  for (const uncertain of session.uncertainExecutions()) {
302
- const verdict = await askPanel(input, uncertainView(uncertain.name, uncertain.executionId));
327
+ // KC3.5 §4: an interrupted ask_user is not a side effect that may
328
+ // have applied — it is a question nobody answered. The COPY says
329
+ // so; the mechanism is untouched (allow → the runtime's own rerun
330
+ // resolution, whose error-fill text this round never edits).
331
+ const view = uncertain.name === "ask_user" ? unansweredAskView(uncertain.executionId) : uncertainView(uncertain.name, uncertain.executionId);
332
+ const verdict = await askPanel(input, view);
303
333
  if (isCancelled() || verdict.action === "cancel") {
304
334
  // round 10: a cancellation NEVER records a verdict — the execution
305
335
  // stays uncertain and durable; no rerun/abandoned is fabricated.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.7.0",
3
+ "version": "0.9.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,18 +18,19 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-core": "0.7.0",
22
- "@vincemakes/kiso-evals": "0.7.0",
23
- "@vincemakes/kiso-mcp-ext": "0.7.0",
24
- "@vincemakes/kiso-provider-anthropic": "0.7.0",
25
- "@vincemakes/kiso-provider-openai": "0.7.0",
26
- "@vincemakes/kiso-runtime": "0.7.0",
27
- "@vincemakes/kiso-skills-ext": "0.7.0",
28
- "@vincemakes/kiso-subagent-ext": "0.7.0",
29
- "@vincemakes/kiso-task-ext": "0.7.0",
30
- "@vincemakes/kiso-tools-node": "0.7.0",
31
- "@vincemakes/kiso-tui": "0.7.0",
32
- "@vincemakes/kiso-tui-cells": "0.7.0"
21
+ "@vincemakes/kiso-ask-ext": "0.9.0",
22
+ "@vincemakes/kiso-core": "0.9.0",
23
+ "@vincemakes/kiso-evals": "0.9.0",
24
+ "@vincemakes/kiso-mcp-ext": "0.9.0",
25
+ "@vincemakes/kiso-provider-anthropic": "0.9.0",
26
+ "@vincemakes/kiso-provider-openai": "0.9.0",
27
+ "@vincemakes/kiso-runtime": "0.9.0",
28
+ "@vincemakes/kiso-skills-ext": "0.9.0",
29
+ "@vincemakes/kiso-subagent-ext": "0.9.0",
30
+ "@vincemakes/kiso-task-ext": "0.9.0",
31
+ "@vincemakes/kiso-tools-node": "0.9.0",
32
+ "@vincemakes/kiso-tui": "0.9.0",
33
+ "@vincemakes/kiso-tui-cells": "0.9.0"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@types/node": "^26.1.2",