@vincemakes/kiso-code 0.1.40 → 0.1.42

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/chat.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * estimates. All bodies moved verbatim from index.ts.
6
6
  */
7
7
  import { type RunUsage } from "@vincemakes/kiso-tui";
8
- import type { AgentSession } from "@vincemakes/kiso-runtime";
8
+ import type { AgentSession, Run } from "@vincemakes/kiso-runtime";
9
9
  import { type LineInput } from "./state.js";
10
10
  /**
11
11
  * The ergonomics batch C8 — the /compact auto-trigger, OPT-IN (default off: only an
@@ -38,12 +38,16 @@ export declare function estimateCtxRatio(session: AgentSession): number;
38
38
  export declare function startStatusSpinner(onTick: (glyph: string) => void): () => void;
39
39
  /**
40
40
  * Consume a run, answering approval pauses as they arrive. `resumeMode`
41
- * marks a session.resume() continuation. v2a: `faux` picks the status
42
- * line's form; `liveInput` (non-null only in interactive chat) carries the
43
- * last line THIS process's readline consumed the double-echo filter.
41
+ * marks a session.resume() continuation. `faux` picks the status line's
42
+ * form. W22: EVERY user_input event renders its UserMessage chip in the
43
+ * body the v2a double-echo filter is retired (the transient input-row
44
+ * echo is UI, the chip is the record; the momentary double-render is
45
+ * the design's explicit point).
44
46
  */
45
- export declare function consumeRun(session: AgentSession, run: AsyncIterable<import("@vincemakes/kiso-core").Event>, input: LineInput, turnNo: number, faux: boolean, liveInput: {
46
- current: string | null;
47
- } | null, statusCb: ((usage: RunUsage, ctxRatio: number) => void) | null): Promise<import("@vincemakes/kiso-core").Event | undefined>;
47
+ export declare function consumeRun(session: AgentSession, run: Run, input: LineInput, turnNo: number, faux: boolean, statusCb: ((usage: RunUsage, ctxRatio: number) => void) | null,
48
+ /** W21: the amend words ("Yes + feedback") ride the NEXT user turn —
49
+ * threaded from chat's submitTurn; absent in the recovery flow
50
+ * (resume) where a dropped amend is noticed instead. */
51
+ submitTurn?: (line: string) => void): Promise<import("@vincemakes/kiso-core").Event | undefined>;
48
52
  /** Interactive REPL: stream events, pause for approvals, Ctrl+C aborts. */
49
53
  export declare function chat(session: AgentSession, faux: boolean, input: LineInput, autoCompact?: AutoCompact): Promise<void>;
package/dist/chat.js CHANGED
@@ -5,12 +5,12 @@
5
5
  * estimates. All bodies moved verbatim from index.ts.
6
6
  */
7
7
  import { readFileSync } from "node:fs";
8
- import { escapeTerminal, kUnit, palette, renderEvent, renderRecap } from "@vincemakes/kiso-tui";
8
+ import { escapeTerminal, kUnit, palette, renderEvent, renderRecap, toolTarget, } from "@vincemakes/kiso-tui";
9
9
  import { editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
11
11
  import { dispatch } from "./dispatch.js";
12
- import { CANCELLED, agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
13
- import { ask, pendingAsk, resolveUncertains } from "./trust-ui.js";
12
+ import { agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
13
+ import { addDontAskAgainRule, askPanel, fixHintFor, pendingAsk, resolveUncertains } from "./trust-ui.js";
14
14
  import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
15
15
  import { MODES, getMode, setMode } from "./mode.js";
16
16
  /** B area: default context window for the ~ctx estimate (config overridable). */
@@ -93,6 +93,40 @@ function approvalDiff(name, input) {
93
93
  return null; // never let the diff break the approval
94
94
  }
95
95
  }
96
+ /** W21 — the panel view for a permission_requested: the rule line (the
97
+ * why-asked speaker + the §3.5 fix hint), the toolTarget title, the
98
+ * "▸ run paused" status, and the ALWAYS-verbose args. */
99
+ function approvalView(name, ev) {
100
+ const speaker = ev.speaker ?? "kiso";
101
+ const input = ev.input ?? {};
102
+ // exactOptionalPropertyTypes: the hint is OMITTED when the speaker has
103
+ // no fix (mode:accept-edits, shell in default) — never `hint: undefined`.
104
+ const hint = fixHintFor(speaker, name);
105
+ return {
106
+ flavor: "approval",
107
+ name,
108
+ title: toolTarget(name, input),
109
+ speaker,
110
+ ...(hint !== undefined ? { hint } : {}),
111
+ statusText: "▸ run paused",
112
+ args: approvalArgs(name, input),
113
+ fallbackQuestion: `approve ${escapeTerminal(name)}? (y/n) `,
114
+ };
115
+ }
116
+ /** The panel's ALWAYS-verbose args: edit_file/write_file → the full ±
117
+ * diff (the tool cell's capped copy never reaches the panel — the
118
+ * human approves the WHOLE change), shell → the full command line,
119
+ * anything else → the pretty-printed JSON. Nothing that is asked for
120
+ * approval is ever truncated. */
121
+ function approvalArgs(name, input) {
122
+ if (name === "edit_file" || name === "write_file") {
123
+ return { kind: "diff", diff: approvalDiff(name, input)?.lines ?? null };
124
+ }
125
+ if (name === "shell") {
126
+ return { kind: "text", lines: [String(input.command ?? "")] };
127
+ }
128
+ return { kind: "text", lines: JSON.stringify(input, null, 2).split("\n") };
129
+ }
96
130
  /**
97
131
  * The ergonomics batch C5 — the translation layer: the tui renders its OWN data shape
98
132
  * (RenderInput, zero kiso-core imports); the CLI translates its Event
@@ -165,11 +199,17 @@ function toRenderInput(ev) {
165
199
  }
166
200
  /**
167
201
  * Consume a run, answering approval pauses as they arrive. `resumeMode`
168
- * marks a session.resume() continuation. v2a: `faux` picks the status
169
- * line's form; `liveInput` (non-null only in interactive chat) carries the
170
- * last line THIS process's readline consumed the double-echo filter.
202
+ * marks a session.resume() continuation. `faux` picks the status line's
203
+ * form. W22: EVERY user_input event renders its UserMessage chip in the
204
+ * body the v2a double-echo filter is retired (the transient input-row
205
+ * echo is UI, the chip is the record; the momentary double-render is
206
+ * the design's explicit point).
171
207
  */
172
- export async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
208
+ export async function consumeRun(session, run, input, turnNo, faux, statusCb,
209
+ /** W21: the amend words ("Yes + feedback") ride the NEXT user turn —
210
+ * threaded from chat's submitTurn; absent in the recovery flow
211
+ * (resume) where a dropped amend is noticed instead. */
212
+ submitTurn) {
173
213
  let last;
174
214
  let usage = { in: null, out: null, cache: null, known: false };
175
215
  // R-C item 4: the per-turn cache miss — the overlap with the previous
@@ -201,17 +241,6 @@ export async function consumeRun(session, run, input, turnNo, faux, liveInput, s
201
241
  else if (thinkingSince === null) {
202
242
  thinkingSince = Date.now();
203
243
  }
204
- // v2a (the double echo): the interactive echo was already rendered by the
205
- // input source — rendering the event again is the double echo.
206
- // v2b: DOCKED — the echo lives in the input row (H), NOT the body;
207
- // the body render is the ONLY visible copy of the sent line.
208
- if (ev.type === "user_input" &&
209
- liveInput !== null &&
210
- liveInput.current === (typeof ev.content === "string" ? ev.content : "") &&
211
- process.stdin.isTTY &&
212
- !dock.active) {
213
- continue;
214
- }
215
244
  // v2d: EVERY event only mutates a cell — the Body is the single
216
245
  // writer of the scroll region, so interleaving is impossible by
217
246
  // construction (ADR-0040).
@@ -291,18 +320,71 @@ export async function consumeRun(session, run, input, turnNo, faux, liveInput, s
291
320
  // v2e: the mini-diff for edit/write at the approval moment —
292
321
  // the human sees the change BEFORE deciding (auto-allowed tools
293
322
  // skip the diff: nobody is looking).
323
+ // W21: the PANEL replaces the line question — the bounded block
324
+ // with the ALWAYS-verbose args (the full diff / command / JSON,
325
+ // nothing the human approves is ever cut) and the numbered
326
+ // options. The verdict maps to the session approvals:
327
+ // - bare No → approve(false) FIRST (the denial settles the
328
+ // request), THEN run.abort() — the run's aborted terminal
329
+ // closes the cell;
330
+ // - No+words → approve(false, words) — the words become the
331
+ // tool_result, the run continues;
332
+ // - Yes+amend → approve(true), the words ride the NEXT turn;
333
+ // - esc → cancel, the conservative denial.
294
334
  const name = ev.name;
295
335
  body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
296
336
  const decisionId = ev.decisionId;
297
- const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
298
- if (answer === CANCELLED) {
299
- // round 10: a cancellation is a CONSERVATIVE denial, explicitly
300
- // distinguished from the user typing "n".
301
- body.notice("[approval cancelled treated as a denial]");
302
- await session.approve(decisionId, false);
303
- continue;
337
+ const verdict = await askPanel(input, approvalView(name, ev));
338
+ switch (verdict.action) {
339
+ case "cancel": {
340
+ // round 10: a cancellation is a CONSERVATIVE denial,
341
+ // explicitly distinguished from the user typing "n".
342
+ body.notice("[approval cancelled — treated as a denial]");
343
+ await session.approve(decisionId, false);
344
+ break;
345
+ }
346
+ case "allow": {
347
+ await session.approve(decisionId, true);
348
+ if (verdict.reason.trim() !== "") {
349
+ if (submitTurn !== undefined)
350
+ submitTurn(verdict.reason);
351
+ else
352
+ body.notice("[amend words dropped — the recovery flow has no live prompt]");
353
+ }
354
+ break;
355
+ }
356
+ case "allow-rule": {
357
+ // R3: the don't-ask-again extension is ALLOW-ONLY (never
358
+ // emits deny or ask — the mode and safe-defaults moats
359
+ // keep their teeth); the generated file is human-editable
360
+ // and human-deletable — that IS the revocation path.
361
+ await session.approve(decisionId, true);
362
+ await addDontAskAgainRule(verdict.rule);
363
+ break;
364
+ }
365
+ case "deny": {
366
+ if (verdict.reason.trim() !== "") {
367
+ // No+words — the words become the tool_result; the
368
+ // run continues with the model seeing the denial.
369
+ await session.approve(decisionId, false, verdict.reason);
370
+ }
371
+ else {
372
+ // bare No — the denial settles the pause FIRST, then
373
+ // the run aborts.
374
+ await session.approve(decisionId, false);
375
+ run.abort();
376
+ }
377
+ break;
378
+ }
304
379
  }
305
- await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
380
+ break;
381
+ }
382
+ case "permission_decided": {
383
+ // A5: the verdict binds INTO the tool cell — the aggregated
384
+ // head row (name + status + decidedBy in ONE row), never a
385
+ // free-standing ` approved` orphan. The render.ts case stays
386
+ // for the PIPE path (the transcript is the raw event stream).
387
+ body.toolVerdict(ev.callId ?? "", ev.decision, ev.decidedBy, ev.reason);
306
388
  break;
307
389
  }
308
390
  case "terminal": {
@@ -315,6 +397,13 @@ export async function consumeRun(session, run, input, turnNo, faux, liveInput, s
315
397
  // the commit loop folds the quiet turn's held cells first (the
316
398
  // fold line lands above the recap, natural cell order).
317
399
  body.endTurn(Math.round(thoughtSeconds));
400
+ // D4: the max_tokens truncation is named, never silent — the
401
+ // honest notice rides after the partial answer, before the
402
+ // recap (the truncation-guard philosophy: the cut is visible
403
+ // in the scrollback, the model's own text intact).
404
+ if (ev.outcome.kind === "max_tokens") {
405
+ body.notice('┌ answer truncated at max_tokens — say "continue" to finish');
406
+ }
318
407
  bodyLog(renderRecap({
319
408
  seconds: Math.round((Date.now() - turnStart) / 1000),
320
409
  tools: toolCount,
@@ -356,10 +445,6 @@ export async function chat(session, faux, input, autoCompact) {
356
445
  let cancelled = false;
357
446
  const turn = (text) => new Promise((resolve, reject) => {
358
447
  queued = Math.max(0, queued - 1); // a queued turn starts
359
- // v2a: the echo filter compares the user_input event against THIS
360
- // turn's own input — lines that arrive ahead of their turn (piped
361
- // bursts, queued replays) must not overwrite the reference.
362
- liveInput.current = text;
363
448
  const run = session.run(text);
364
449
  currentRun = run;
365
450
  turnNo += 1;
@@ -375,7 +460,7 @@ export async function chat(session, faux, input, autoCompact) {
375
460
  (async () => {
376
461
  let last;
377
462
  try {
378
- last = await consumeRun(session, run, input, myTurn, faux, liveInput, statusCb);
463
+ last = await consumeRun(session, run, input, myTurn, faux, statusCb, submitTurn);
379
464
  stopSpinner();
380
465
  paintIdle();
381
466
  currentRun = null;
@@ -458,12 +543,14 @@ export async function chat(session, faux, input, autoCompact) {
458
543
  // B area: user-turn counter for the status line. /last and /think read
459
544
  // the body (the ToolCell / ThinkingCell final states).
460
545
  let turnNo = 0;
461
- // v2a: the last line THIS process's readline consumed the double-echo
462
- // filter (see consumeRun). Only interactive chat sets it.
463
- const liveInput = { current: null };
464
- // v2c: turns submitted while another runs are QUEUED on the chain — the
465
- // live count rides the status bar (+N queued).
546
+ // v2c: turns submitted while another runs are QUEUED on the chain —
547
+ // the live count rides the status bar (+N queued).
466
548
  let queued = 0;
549
+ // W22: the pending turns — the LIVE slots the chips + the ↑/esc pop
550
+ // read (the dock renders the lines, the editor pops the last one).
551
+ // A slot leaves the queue when its turn STARTS or when the user
552
+ // pops it (cancelled — the chain segment skips it).
553
+ const pendingTurns = [];
467
554
  // v2b: the live status bar (docked only). Modes: /mode switches repaint
468
555
  // it immediately through paintStatus (the last turn stats are kept).
469
556
  // v3 §03: the status bar has TWO states. Idle: the mode is ALWAYS
@@ -496,9 +583,35 @@ export async function chat(session, faux, input, autoCompact) {
496
583
  paintRunning();
497
584
  };
498
585
  const submitTurn = (line) => {
586
+ const slot = { line, cancelled: false };
587
+ pendingTurns.push(slot);
499
588
  queued += 1;
500
- chainRef.current = chainRef.current.then(() => turn(line));
589
+ chainRef.current = chainRef.current.then(() => {
590
+ if (slot.cancelled)
591
+ return; // the pop already dropped it — no double count
592
+ const idx = pendingTurns.indexOf(slot);
593
+ if (idx >= 0)
594
+ pendingTurns.splice(idx, 1); // the chip leaves when the turn STARTS
595
+ return turn(line);
596
+ });
597
+ };
598
+ // W22: the ↑/esc pop — the LAST queued slot leaves the queue
599
+ // (cancelled + spliced + counted down); null when the queue is
600
+ // empty. The chain segment skips the cancelled slot, so the popped
601
+ // message NEVER runs — it returns to the editor instead.
602
+ const popQueue = () => {
603
+ const slot = pendingTurns[pendingTurns.length - 1];
604
+ if (slot === undefined)
605
+ return null;
606
+ slot.cancelled = true;
607
+ pendingTurns.pop();
608
+ queued = Math.max(0, queued - 1);
609
+ return slot.line;
501
610
  };
611
+ // W22: the visibility invariant's binds — the dock renders the
612
+ // pending chips (+N queued), the editor routes the pop keys.
613
+ dock.bindQueue(() => pendingTurns.map((s) => s.line));
614
+ input.bindQueue(() => pendingTurns.map((s) => s.line), popQueue);
502
615
  const dispatchCtx = {
503
616
  session,
504
617
  input,
@@ -546,7 +659,7 @@ export async function chat(session, faux, input, autoCompact) {
546
659
  const recoveryRun = session.resume();
547
660
  currentRun = recoveryRun;
548
661
  turnNo += 1;
549
- const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
662
+ const last = await consumeRun(session, recoveryRun, input, turnNo, faux, statusCb, submitTurn);
550
663
  currentRun = null;
551
664
  failOnFauxExhaustion(last, faux, input);
552
665
  maybeAutoCompact(); // the ergonomics batch C8: the recovery run ended too — same check (awaited by the exit re-await)
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions,
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
- import { body, bodyLog, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, projectExtensions, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, userExtensions, VERSION } from "./state.js";
34
+ import { body, bodyLog, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, projectExtensions, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, userExtensions, VERSION } from "./state.js";
35
35
  import { interactivePrompt, resolveProjectTrust } from "./trust-ui.js";
36
36
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
37
37
  import { autoCompactFromEnv, chat, contextWindowTokens } from "./chat.js";
@@ -81,6 +81,19 @@ function readlineInput(rl) {
81
81
  /* the rl.question stays pending; the settled branch re-emits
82
82
  * the answer as a new line. */
83
83
  },
84
+ // W21: readline is never asked — askPanel's non-TTY branch
85
+ // auto-denies before any panel opens (the pipe path).
86
+ panelAsk() {
87
+ /* unreachable — non-TTY asks auto-deny in askPanel */
88
+ },
89
+ panelCancel() {
90
+ /* unreachable */
91
+ },
92
+ // W22: readline has no ↑/esc pop — the pipe path shows no chips
93
+ // and pops nothing (the queue drains on its own).
94
+ bindQueue() {
95
+ /* unreachable — no raw keys in the pipe path */
96
+ },
84
97
  emitLine(line) {
85
98
  rl.emit("line", line);
86
99
  },
@@ -125,6 +138,19 @@ function editorInput(editor) {
125
138
  cancelQuestion() {
126
139
  editor.cancelQuestion();
127
140
  },
141
+ // W21: the panel — the editor's own state machine takes the
142
+ // keys; the compositor renders it via the bound state.
143
+ panelAsk(view, onCommit) {
144
+ editor.beginPanel(view, onCommit);
145
+ },
146
+ panelCancel() {
147
+ editor.cancelPanel();
148
+ },
149
+ // W22: the pending-turn queue — the ↑ pop walk (the keys); the
150
+ // chips are the compositor's bindQueue (the dock side).
151
+ bindQueue(state, pop) {
152
+ editor.bindQueue(state, pop);
153
+ },
128
154
  emitLine() {
129
155
  /* the editor's buffer survives a cancelled question — its text
130
156
  * becomes the next turn on Enter (the readline re-emit
@@ -157,6 +183,7 @@ function makeLineInput() {
157
183
  // pipe bytes do not change)
158
184
  dock.bindInput(() => editor.dockState(), "› ");
159
185
  dock.bindMenu(() => editor.menuState()); // v3 §04: the slash-command menu
186
+ dock.bindApproval(() => editor.panelState()); // W21: the panel's bound state
160
187
  return editorInput(editor);
161
188
  }
162
189
  return readlineInput(createInterface({ input: process.stdin, output: process.stdout }));
@@ -302,6 +329,13 @@ async function makeAgent(fauxSkipTurns = 0, input, modelFlag) {
302
329
  setCurrentModelName(resolved.name);
303
330
  }
304
331
  setAgentModel(model); // v2b: the status bar shows it
332
+ // W21: the extensions array is built ONCE per agent and shared with
333
+ // the runtime by reference — the don't-ask-again writer pushes the
334
+ // generated extension into it so a first-time rule joins the chain
335
+ // at the NEXT run (the run's policies are fixed at its start; run.ts
336
+ // re-reads the config's extensions array per run).
337
+ const extensions = [...modeExtensions(), ...loadedExtensions];
338
+ setCurrentAgentExtensions(extensions);
305
339
  const definition = {
306
340
  model,
307
341
  store,
@@ -327,7 +361,7 @@ async function makeAgent(fauxSkipTurns = 0, input, modelFlag) {
327
361
  // Modes: the five tiers join at the CHAIN HEAD, before the user/
328
362
  // project extensions (the deny>ask>allow composition keeps a user
329
363
  // deny winning over any mode tier — bypass included).
330
- extensions: [...modeExtensions(), ...loadedExtensions],
364
+ extensions,
331
365
  ...(resolved !== null
332
366
  ? {
333
367
  provider: resolved.profile.kind,
package/dist/resume.js CHANGED
@@ -48,7 +48,7 @@ export async function resume(session, prompt, faux, input) {
48
48
  });
49
49
  try {
50
50
  turnNo += 1;
51
- const last = await consumeRun(session, run, input, turnNo, faux, null, statusCb);
51
+ const last = await consumeRun(session, run, input, turnNo, faux, statusCb);
52
52
  failOnFauxExhaustion(last, faux, input);
53
53
  }
54
54
  finally {
package/dist/state.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * creates the mutable ones (setBody / setAgentModel / setExtensionLists);
6
6
  * the moved modules read and mutate at call time.
7
7
  */
8
- import { Dock, type Body } from "@vincemakes/kiso-tui";
8
+ import { Dock, type Body, type PanelVerdict, type PanelView } from "@vincemakes/kiso-tui";
9
9
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
10
10
  /** finding #11: KISO_HOME is the ONE root — every default path derives from
11
11
  * it (sessions, trust, extensions, mcp config, skills). The dedicated
@@ -32,6 +32,16 @@ export interface LineInput {
32
32
  onExpand(cb: () => void): void;
33
33
  question(query: string, cb: (answer: string) => void): void;
34
34
  cancelQuestion(): void;
35
+ /** W21: open the approval panel — the editor's state machine takes
36
+ * the keys (the digits/y/n/tab/esc/enter routing, the rule input,
37
+ * the tab-amend), the compositor renders the block + the leads. */
38
+ panelAsk(view: PanelView, onCommit: (v: PanelVerdict) => void): void;
39
+ /** W21: cancel the panel — the SIGINT pair to panelAsk. */
40
+ panelCancel(): void;
41
+ /** W22: bind the pending-turn queue — the ↑ pop walks the CLI's
42
+ * live slots (each pop cancels the turn), esc ends the walk after
43
+ * one more pop. The chips are the compositor's own bindQueue. */
44
+ bindQueue(state: () => readonly string[], pop: () => string | null): void;
35
45
  emitLine(line: string): void;
36
46
  line(): string;
37
47
  clearLine(): void;
@@ -84,6 +94,13 @@ export declare let userExtensions: readonly KisoExtension[];
84
94
  * banner distinguishes them from the user-level ones. */
85
95
  export declare let projectExtensions: readonly KisoExtension[];
86
96
  export declare function setExtensionLists(user: readonly KisoExtension[], project: readonly KisoExtension[], loaded: readonly KisoExtension[]): void;
97
+ /** W21: the CURRENT agent's extensions array — set by makeAgent, the
98
+ * don't-ask-again writer pushes the generated extension into it so a
99
+ * first-time rule joins the chain at the NEXT run (the run's policies
100
+ * are fixed at its start; the array is shared by reference with the
101
+ * runtime's session config — run.ts re-reads it per run). */
102
+ export declare let currentAgentExtensions: KisoExtension[];
103
+ export declare function setCurrentAgentExtensions(value: KisoExtension[]): void;
87
104
  /** E3: temp artifacts of the mcp/skills merge — removed on exit. */
88
105
  export declare const mergedTempPaths: string[];
89
106
  /** The CLI's own version — read from the package.json next to the build. */
package/dist/state.js CHANGED
@@ -89,6 +89,15 @@ export function setExtensionLists(user, project, loaded) {
89
89
  projectExtensions = project;
90
90
  loadedExtensions = loaded;
91
91
  }
92
+ /** W21: the CURRENT agent's extensions array — set by makeAgent, the
93
+ * don't-ask-again writer pushes the generated extension into it so a
94
+ * first-time rule joins the chain at the NEXT run (the run's policies
95
+ * are fixed at its start; the array is shared by reference with the
96
+ * runtime's session config — run.ts re-reads it per run). */
97
+ export let currentAgentExtensions = [];
98
+ export function setCurrentAgentExtensions(value) {
99
+ currentAgentExtensions = value;
100
+ }
92
101
  /** E3: temp artifacts of the mcp/skills merge — removed on exit. */
93
102
  export const mergedTempPaths = [];
94
103
  /** The CLI's own version — read from the package.json next to the build. */
@@ -1,31 +1,62 @@
1
1
  /**
2
2
  * The ergonomics batch B4 (pure move) — the human-facing question UI: the E3 project
3
- * trust gate (ADR-0037), the mcp/skills env merges, the generic ask()
4
- * (approvals, trust, uncertain resolutions), and the uncertain-execution
3
+ * trust gate (ADR-0037), the mcp/skills env merges, the W21 approval
4
+ * panel (askPanel the bounded block, the verdict mapping lives in
5
+ * chat.ts), the don't-ask-again rule writer, and the uncertain-execution
5
6
  * decisions. All bodies moved verbatim from index.ts.
6
7
  */
8
+ import { type PanelVerdict, type PanelView } from "@vincemakes/kiso-tui";
7
9
  import { type ProjectArtifacts } from "@vincemakes/kiso-runtime";
8
10
  import type { AgentSession } from "@vincemakes/kiso-runtime";
9
- import { CANCELLED, type LineInput } from "./state.js";
11
+ import { type LineInput } from "./state.js";
10
12
  /** v2a: the interactive prompt — blue, the identity accent. readline owns
11
13
  * the echo of what the user types; we own the prompt's color. (v2c: the
12
14
  * readline prompt keeps "you> " — the brick ▌ is the dock's row only;
13
15
  * pipe bytes must not change.) */
14
16
  export declare function interactivePrompt(): string;
15
17
  /**
16
- * Ask the human a question. Non-interactive stdin (piped, CI) cannot wait
17
- * forever: approvals auto-deny and uncertain executions auto-abandon, both
18
- * printed loudly never silently ignored, never hung (Area 7).
18
+ * W21 — ask the human with the approval panel: the bounded block that
19
+ * replaces the running tool's live window while the approval is pending.
20
+ * The editor owns the keys (the digits/tab/esc/enter routing, the rule
21
+ * input, the tab-amend); the compositor renders the block + the leads;
22
+ * the CLI maps the verdict (the bare-No abort, the No+words tool_result,
23
+ * the allow-amend words riding the next turn — all live in chat.ts,
24
+ * never here).
19
25
  *
20
- * rounds 8/10: the question is ABORTABLE a pending rl.question is registered in
21
- * `pendingAsk` and the SIGINT handler resolves it with the CANCELLED
22
- * sentinel. The rl.question callback is NOT left dangling: an input that
23
- * arrives after the cancellation is re-emitted as a fresh "line" — it
24
- * becomes the next user turn instead of being swallowed by the dead
25
- * question.
26
+ * Non-interactive stdin (piped, CI) cannot wait forever: the approval
27
+ * AUTO-DENIES, printed loudly never silently ignored, never hung
28
+ * (Area 7). A TTY without a dock (rows < 4) falls back to the v2a line
29
+ * question (y/n allow/deny) the panel cannot render without the
30
+ * dock's live region.
31
+ *
32
+ * rounds 8/10: the ask is ABORTABLE — registered in `pendingAsk`, the
33
+ * SIGINT handler resolves it with { action: "cancel" } (the panel closes
34
+ * through the editor's panelCancel, the dock-less question through
35
+ * cancelQuestion). The dock-less question callback is NOT left dangling:
36
+ * an input that arrives after the cancellation is re-emitted as a fresh
37
+ * "line" — it becomes the next user turn instead of being swallowed by
38
+ * the dead question.
26
39
  */
27
40
  export declare let pendingAsk: (() => void) | null;
28
- export declare function ask(input: LineInput, question: string): Promise<string | typeof CANCELLED>;
41
+ export declare function askPanel(input: LineInput, view: PanelView): Promise<PanelVerdict>;
42
+ /**
43
+ * W21/R3 — the "2 Yes, don't ask again for <tool>" rule: a GENERATED
44
+ * extension, ALLOW-ONLY by construction (it never emits deny or ask —
45
+ * the mode moat and the safe-defaults moat keep their teeth). The file
46
+ * is HUMAN-EDITABLE and HUMAN-DELETABLE — that IS the revocation path.
47
+ * The loader sorts *.mjs alphabetically, so dont-ask-again.mjs binds
48
+ * BEFORE safe-defaults.mjs: the allow is the chain's first word, the
49
+ * safe-defaults deny moat still wins (deny > allow — the R3 order).
50
+ */
51
+ export declare function addDontAskAgainRule(rule: string): Promise<void>;
52
+ /**
53
+ * W21 — the §3.5 fix-hint table: per speaker, the ONE-line path to stop
54
+ * being asked. The hint names the FIX, never a bypass ("switch to
55
+ * bypass" would be an approval-hole nudge — the R3 ruling). The
56
+ * extension hint collapses the home prefix to ~ (the hint must stay
57
+ * short enough for the one-row rule line at narrow widths).
58
+ */
59
+ export declare function fixHintFor(speaker: string, tool: string): string | undefined;
29
60
  /**
30
61
  * E3 — the project-level trust gate (ADR-0037): capability is trusted by
31
62
  * content digest, not by directory. Runs BEFORE any extension loads — the
@@ -46,5 +77,9 @@ export declare function resolveProjectTrust(input: LineInput): Promise<ProjectAr
46
77
  * with project-wins and a stderr note. Exported for tests.
47
78
  */
48
79
  export declare function applyProjectMerges(artifacts: ProjectArtifacts): void;
49
- /** Decide every uncertain execution with the human (r)erun/(a)bandon. */
80
+ /** Decide every uncertain execution with the human the panel's simple
81
+ * flavor: 1 Yes = rerun, 3 No = abandon; a cancel records NOTHING (the
82
+ * execution stays uncertain and durable — no rerun/abandoned is
83
+ * fabricated, round 10). The resolution feedback lands in the body —
84
+ * the body is the single stdout writer, never a stray console.log. */
50
85
  export declare function resolveUncertains(session: AgentSession, input: LineInput, isCancelled: () => boolean): Promise<void>;
package/dist/trust-ui.js CHANGED
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * The ergonomics batch B4 (pure move) — the human-facing question UI: the E3 project
3
- * trust gate (ADR-0037), the mcp/skills env merges, the generic ask()
4
- * (approvals, trust, uncertain resolutions), and the uncertain-execution
3
+ * trust gate (ADR-0037), the mcp/skills env merges, the W21 approval
4
+ * panel (askPanel the bounded block, the verdict mapping lives in
5
+ * chat.ts), the don't-ask-again rule writer, and the uncertain-execution
5
6
  * decisions. All bodies moved verbatim from index.ts.
6
7
  */
7
- import { existsSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
8
- import { tmpdir } from "node:os";
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
9
+ import { homedir, tmpdir } from "node:os";
9
10
  import { join } from "node:path";
11
+ import { pathToFileURL } from "node:url";
10
12
  import { escapeTerminal, palette } from "@vincemakes/kiso-tui";
11
13
  import { projectArtifacts, recordTrust, trustFor } from "@vincemakes/kiso-runtime";
12
- import { CANCELLED, bodyLog, dock, kisoHome, mergedTempPaths } from "./state.js";
14
+ import { bodyLog, currentAgentExtensions, dock, extensionsDir, kisoHome, mergedTempPaths } from "./state.js";
13
15
  import { loadUserConfig, resolveProjectTrustPolicy } from "./config.js";
16
+ import { getMode } from "./mode.js";
14
17
  /** v2a: the interactive prompt — blue, the identity accent. readline owns
15
18
  * the echo of what the user types; we own the prompt's color. (v2c: the
16
19
  * readline prompt keeps "you> " — the brick ▌ is the dock's row only;
@@ -20,31 +23,33 @@ export function interactivePrompt() {
20
23
  return `${p.bold}you> ${p.reset}`;
21
24
  }
22
25
  /**
23
- * Ask the human a question. Non-interactive stdin (piped, CI) cannot wait
24
- * forever: approvals auto-deny and uncertain executions auto-abandon, both
25
- * printed loudly never silently ignored, never hung (Area 7).
26
+ * W21 — ask the human with the approval panel: the bounded block that
27
+ * replaces the running tool's live window while the approval is pending.
28
+ * The editor owns the keys (the digits/tab/esc/enter routing, the rule
29
+ * input, the tab-amend); the compositor renders the block + the leads;
30
+ * the CLI maps the verdict (the bare-No abort, the No+words tool_result,
31
+ * the allow-amend words riding the next turn — all live in chat.ts,
32
+ * never here).
26
33
  *
27
- * rounds 8/10: the question is ABORTABLE a pending rl.question is registered in
28
- * `pendingAsk` and the SIGINT handler resolves it with the CANCELLED
29
- * sentinel. The rl.question callback is NOT left dangling: an input that
30
- * arrives after the cancellation is re-emitted as a fresh "line" — it
31
- * becomes the next user turn instead of being swallowed by the dead
32
- * question.
34
+ * Non-interactive stdin (piped, CI) cannot wait forever: the approval
35
+ * AUTO-DENIES, printed loudly never silently ignored, never hung
36
+ * (Area 7). A TTY without a dock (rows < 4) falls back to the v2a line
37
+ * question (y/n allow/deny) the panel cannot render without the
38
+ * dock's live region.
39
+ *
40
+ * rounds 8/10: the ask is ABORTABLE — registered in `pendingAsk`, the
41
+ * SIGINT handler resolves it with { action: "cancel" } (the panel closes
42
+ * through the editor's panelCancel, the dock-less question through
43
+ * cancelQuestion). The dock-less question callback is NOT left dangling:
44
+ * an input that arrives after the cancellation is re-emitted as a fresh
45
+ * "line" — it becomes the next user turn instead of being swallowed by
46
+ * the dead question.
33
47
  */
34
48
  export let pendingAsk = null;
35
- export function ask(input, question) {
49
+ export function askPanel(input, view) {
36
50
  if (!process.stdin.isTTY) {
37
- console.log(`[non-interactive — no human to ask: ${question}]`);
38
- return Promise.resolve("");
39
- }
40
- // v2b: docked — the question takes over the status position, the
41
- // answer lands at the input line. v2c: a TTY without a dock (rows < 4)
42
- // prints the question into the body — the editor cannot show it.
43
- if (dock.active) {
44
- dock.showQuestion(question);
45
- }
46
- else {
47
- bodyLog(question);
51
+ console.log(`[non-interactive — no human to ask: ${view.fallbackQuestion}]`);
52
+ return Promise.resolve({ action: "deny", reason: "no human to ask" });
48
53
  }
49
54
  return new Promise((resolve) => {
50
55
  let settled = false;
@@ -53,28 +58,108 @@ export function ask(input, question) {
53
58
  return;
54
59
  settled = true;
55
60
  pendingAsk = null;
56
- input.cancelQuestion();
57
- resolve(CANCELLED); // the run is aborting — the question is dead
58
- };
59
- // v2b: docked — the question reads at the input line, whose prompt
60
- // is the same blue you> (the editor's brick row; the readline path
61
- // passes the plain question). An empty prompt would start readline
62
- // at column 1 while the dock renders "you> " — the typed answer
63
- // would land on the prompt and drift (probe-confirmed).
64
- input.question(dock.active ? interactivePrompt() : question, (answer) => {
65
- if (settled) {
66
- // The question was cancelled; this line is a NEW user turn.
67
- input.emitLine(answer);
68
- return;
69
- }
70
- settled = true;
71
- pendingAsk = null;
72
61
  if (dock.active)
73
- dock.clearQuestion();
74
- resolve(answer);
75
- });
62
+ input.panelCancel();
63
+ else
64
+ input.cancelQuestion();
65
+ resolve({ action: "cancel" });
66
+ };
67
+ if (dock.active) {
68
+ input.panelAsk(view, (verdict) => {
69
+ if (settled)
70
+ return; // a cancelled panel's late commit is dead
71
+ settled = true;
72
+ pendingAsk = null;
73
+ resolve(verdict);
74
+ });
75
+ }
76
+ else {
77
+ // v2c: a TTY without a dock (rows < 4) — the fallback question
78
+ // in the body; the y/n line answer maps to the verdicts.
79
+ bodyLog(view.fallbackQuestion);
80
+ input.question(view.fallbackQuestion, (answer) => {
81
+ if (settled) {
82
+ // The question was cancelled; this line is a NEW user turn.
83
+ input.emitLine(answer);
84
+ return;
85
+ }
86
+ settled = true;
87
+ pendingAsk = null;
88
+ const yes = answer.trim().toLowerCase().startsWith("y");
89
+ resolve(yes ? { action: "allow", reason: "" } : { action: "deny", reason: "" });
90
+ });
91
+ }
76
92
  });
77
93
  }
94
+ /**
95
+ * W21/R3 — the "2 Yes, don't ask again for <tool>" rule: a GENERATED
96
+ * extension, ALLOW-ONLY by construction (it never emits deny or ask —
97
+ * the mode moat and the safe-defaults moat keep their teeth). The file
98
+ * is HUMAN-EDITABLE and HUMAN-DELETABLE — that IS the revocation path.
99
+ * The loader sorts *.mjs alphabetically, so dont-ask-again.mjs binds
100
+ * BEFORE safe-defaults.mjs: the allow is the chain's first word, the
101
+ * safe-defaults deny moat still wins (deny > allow — the R3 order).
102
+ */
103
+ export async function addDontAskAgainRule(rule) {
104
+ const dir = extensionsDir();
105
+ const file = join(dir, "dont-ask-again.mjs");
106
+ const existed = existsSync(file);
107
+ const module = [
108
+ "// generated by kiso — the \"2 Yes, don't ask again for <tool>\" rule.",
109
+ "// HUMAN-EDITABLE and HUMAN-DELETABLE — this file IS the revocation path:",
110
+ "// delete a rule from the set (or the whole file) to be asked again.",
111
+ "// ALLOW-ONLY by design (R3): never emits deny or ask — the mode moat",
112
+ "// and the safe-defaults moat keep their teeth.",
113
+ `export const RULES = new Set(${JSON.stringify([rule])});`,
114
+ "",
115
+ "export default {",
116
+ ' name: "dont-ask-again",',
117
+ " approvals: [",
118
+ " {",
119
+ " decide(call) {",
120
+ ' return RULES.has(call.name) ? { action: "allow" } : { action: "abstain" };',
121
+ " },",
122
+ " },",
123
+ " ],",
124
+ "};",
125
+ "",
126
+ ].join("\n");
127
+ mkdirSync(dir, { recursive: true });
128
+ writeFileSync(file, module, "utf8");
129
+ // The live in-session effect: a file that existed at startup was
130
+ // imported by the loader — ONE namespace — so mutating the exported
131
+ // RULES Set is live NOW (the current run's chain reads the same Set).
132
+ const mod = (await import(pathToFileURL(file).href));
133
+ if (mod.RULES !== undefined && !mod.RULES.has(rule))
134
+ mod.RULES.add(rule);
135
+ // A FIRST-time rule (a file that did not exist at startup) is not yet
136
+ // in the current run's chain — the chain is fixed at a run's start —
137
+ // so the generated extension joins the NEXT run's policies (run.ts
138
+ // re-reads the session config's extensions array, shared by
139
+ // reference). The honest story: a new rule applies from the next run.
140
+ if (!existed && mod.default !== undefined) {
141
+ currentAgentExtensions.push(mod.default);
142
+ }
143
+ }
144
+ /**
145
+ * W21 — the §3.5 fix-hint table: per speaker, the ONE-line path to stop
146
+ * being asked. The hint names the FIX, never a bypass ("switch to
147
+ * bypass" would be an approval-hole nudge — the R3 ruling). The
148
+ * extension hint collapses the home prefix to ~ (the hint must stay
149
+ * short enough for the one-row rule line at narrow widths).
150
+ */
151
+ export function fixHintFor(speaker, tool) {
152
+ if (speaker === "mode:default") {
153
+ if (tool === "shell")
154
+ return undefined;
155
+ return "/mode accept-edits auto-approves edits";
156
+ }
157
+ if (speaker === "mode:manual" || speaker === "mode:plan")
158
+ return "/mode default";
159
+ if (speaker === "mode:accept-edits" || speaker === "mode:bypass")
160
+ return undefined;
161
+ return `edit ${join(extensionsDir(), `${speaker}.mjs`).replace(homedir(), "~")}`;
162
+ }
78
163
  /**
79
164
  * E3 — the project-level trust gate (ADR-0037): capability is trusted by
80
165
  * content digest, not by directory. Runs BEFORE any extension loads — the
@@ -110,14 +195,30 @@ export async function resolveProjectTrust(input) {
110
195
  console.error(`[project .kiso] found ${artifacts.files.length} artifact(s) in ${artifacts.root} — not trusted, not loaded (run kiso interactively once to decide)`);
111
196
  return null;
112
197
  }
113
- // v2c: the shared input (the editor on a TTY) reads the answer; the
114
- // dock shows the question at the status position.
198
+ // v2c: the shared input (the editor on a TTY) reads the answer.
199
+ // W21: the trust gate is the panel's SIMPLE flavor — the ruleOverride
200
+ // carries the question, the args the artifact listing (the same rows
201
+ // the bodyLog below records, verbatim — the listing still lands in
202
+ // the scrollback; the panel is a bounded block, the record is not).
115
203
  bodyLog(`[project .kiso] ${artifacts.root}`);
116
204
  for (const f of artifacts.files) {
117
205
  bodyLog(` ${f.path} (${f.digest.slice(0, 6)})`);
118
206
  }
119
- const answer = await ask(input, `trust this project's .kiso? (y/n) `);
120
- const granted = answer !== CANCELLED && answer.trim().toLowerCase().startsWith("y");
207
+ const verdict = await askPanel(input, {
208
+ flavor: "simple",
209
+ name: "project trust",
210
+ title: artifacts.root,
211
+ speaker: "kiso",
212
+ statusText: "▸ project trust",
213
+ args: { kind: "text", lines: artifacts.files.map((f) => `${f.path} (${f.digest.slice(0, 6)})`) },
214
+ ruleOverride: "trust this project's .kiso?",
215
+ fallbackQuestion: `trust this project's .kiso? (y/n) `,
216
+ });
217
+ // A cancel is a "no" HERE — refused is sticky, the project does not
218
+ // load (re-evaluate by deleting the trust line or changing a file).
219
+ // The non-TTY branch above returned WITHOUT a record so an interactive
220
+ // run can still decide later.
221
+ const granted = verdict.action === "allow";
121
222
  recordTrust({ root: artifacts.root, digest: artifacts.digest, decision: granted ? "granted" : "refused" });
122
223
  if (!granted)
123
224
  return null;
@@ -209,17 +310,30 @@ function readdirSyncSafe(dir) {
209
310
  return []; // no skills dir on either level = nothing to merge
210
311
  }
211
312
  }
212
- /** Decide every uncertain execution with the human (r)erun/(a)bandon. */
313
+ /** Decide every uncertain execution with the human the panel's simple
314
+ * flavor: 1 Yes = rerun, 3 No = abandon; a cancel records NOTHING (the
315
+ * execution stays uncertain and durable — no rerun/abandoned is
316
+ * fabricated, round 10). The resolution feedback lands in the body —
317
+ * the body is the single stdout writer, never a stray console.log. */
213
318
  export async function resolveUncertains(session, input, isCancelled) {
214
319
  for (const uncertain of session.uncertainExecutions()) {
215
- const answer = await ask(input, `⚠ interrupted execution: ${escapeTerminal(uncertain.name)} (${uncertain.executionId}) — did it apply? (r)erun / (a)bandon: `);
216
- if (isCancelled() || answer === CANCELLED) {
320
+ const verdict = await askPanel(input, {
321
+ flavor: "simple",
322
+ name: "uncertain execution",
323
+ title: `${uncertain.name} (${uncertain.executionId})`,
324
+ speaker: "kiso",
325
+ statusText: "▸ uncertain execution",
326
+ args: { kind: "text", lines: [uncertain.executionId] },
327
+ ruleOverride: "did the interrupted execution apply? — 1 rerun · 3 abandon",
328
+ fallbackQuestion: `⚠ interrupted execution: ${escapeTerminal(uncertain.name)} (${uncertain.executionId}) — did it apply? (y)es / (n)o `,
329
+ });
330
+ if (isCancelled() || verdict.action === "cancel") {
217
331
  // round 10: a cancellation NEVER records a verdict — the execution
218
332
  // stays uncertain and durable; no rerun/abandoned is fabricated.
219
333
  return;
220
334
  }
221
- const resolution = answer.trim().toLowerCase().startsWith("r") ? "rerun" : "abandoned";
335
+ const resolution = verdict.action === "allow" ? "rerun" : "abandoned";
222
336
  await session.resolveUncertain(uncertain.executionId, resolution);
223
- console.log(` ${resolution}\n`);
337
+ bodyLog(` ${resolution}`);
224
338
  }
225
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "description": "kiso CLI — the coding-agent reference product: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,13 +18,14 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-core": "0.1.31",
22
- "@vincemakes/kiso-evals": "0.1.32",
23
- "@vincemakes/kiso-provider-anthropic": "0.1.32",
24
- "@vincemakes/kiso-provider-openai": "0.1.32",
25
- "@vincemakes/kiso-runtime": "0.1.32",
26
- "@vincemakes/kiso-tools-node": "0.1.32",
27
- "@vincemakes/kiso-tui": "0.1.40"
21
+ "@vincemakes/kiso-core": "0.1.33",
22
+ "@vincemakes/kiso-evals": "0.1.34",
23
+ "@vincemakes/kiso-provider-anthropic": "0.1.34",
24
+ "@vincemakes/kiso-provider-openai": "0.1.34",
25
+ "@vincemakes/kiso-runtime": "0.1.34",
26
+ "@vincemakes/kiso-tools-node": "0.1.34",
27
+ "@vincemakes/kiso-tui": "0.1.42",
28
+ "@vincemakes/kiso-tui-cells": "0.1.42"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@types/node": "^26.1.2",