@vincemakes/kiso-code 0.39.2 → 0.40.1

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/attachments.d.ts +1 -1
  3. package/dist/attachments.js +11 -4
  4. package/dist/builtin.d.ts +12 -0
  5. package/dist/builtin.js +22 -1
  6. package/dist/chat.d.ts +31 -3
  7. package/dist/chat.js +169 -30
  8. package/dist/config.d.ts +11 -1
  9. package/dist/config.js +34 -3
  10. package/dist/dispatch.d.ts +12 -2
  11. package/dist/dispatch.js +165 -24
  12. package/dist/floor.d.ts +80 -0
  13. package/dist/floor.js +652 -0
  14. package/dist/index.d.ts +21 -2
  15. package/dist/index.js +463 -46
  16. package/dist/mode.d.ts +12 -4
  17. package/dist/mode.js +32 -10
  18. package/dist/projects.d.ts +106 -0
  19. package/dist/projects.js +212 -0
  20. package/dist/protected-shell.d.ts +88 -0
  21. package/dist/protected-shell.js +406 -0
  22. package/dist/protected-writes.d.ts +29 -0
  23. package/dist/protected-writes.js +54 -0
  24. package/dist/provider-label.d.ts +32 -0
  25. package/dist/provider-label.js +53 -0
  26. package/dist/readonly-shell.d.ts +58 -0
  27. package/dist/readonly-shell.js +590 -0
  28. package/dist/replay.d.ts +53 -0
  29. package/dist/replay.js +158 -0
  30. package/dist/resume-tail.js +4 -1
  31. package/dist/resume.js +3 -3
  32. package/dist/retries.d.ts +9 -0
  33. package/dist/retries.js +16 -0
  34. package/dist/session-cards.d.ts +33 -4
  35. package/dist/session-cards.js +44 -3
  36. package/dist/session-migration.d.ts +104 -0
  37. package/dist/session-migration.js +319 -0
  38. package/dist/shell-words.d.ts +134 -0
  39. package/dist/shell-words.js +628 -0
  40. package/dist/skill-invoke.d.ts +58 -0
  41. package/dist/skill-invoke.js +139 -0
  42. package/dist/state.d.ts +80 -1
  43. package/dist/state.js +113 -2
  44. package/dist/trust-ui.js +29 -5
  45. package/package.json +15 -15
package/README.md CHANGED
@@ -44,7 +44,7 @@ kiso sessions list durable sessions
44
44
  kiso help usage + configuration reference
45
45
  ```
46
46
 
47
- Sessions live under `~/.kiso/sessions`; kill the process — `kill -9`
47
+ Sessions live under `~/.kiso/projects/<project>/`, one folder per project; kill the process — `kill -9`
48
48
  included — and `kiso resume` continues exactly where the log ends.
49
49
 
50
50
  **Platforms:** macOS / Linux (Node ≥ 22). Windows is unsupported.
@@ -38,4 +38,4 @@ export declare function sniff(buf: Buffer): "image/png" | "image/jpeg" | "image/
38
38
  * standing in the text. Silently dropping it would tell the model about
39
39
  * a file it cannot see, which is worse than telling it nothing.
40
40
  */
41
- export declare function attachImages(text: string, files?: ReadonlyMap<number, string>): string | ContentBlock[];
41
+ export declare function attachImages(text: string, files?: ReadonlyMap<number, string>, protectedFiles?: readonly string[]): string | ContentBlock[];
@@ -21,6 +21,7 @@
21
21
  * hand its temp file to when it lands.
22
22
  */
23
23
  import { readFileSync, statSync } from "node:fs";
24
+ import { isProtectedPath, protectedIdentity } from "@vincemakes/kiso-tools-node";
24
25
  /**
25
26
  * The size a single image may reach before it is refused.
26
27
  *
@@ -55,7 +56,12 @@ const CANDIDATES = [
55
56
  /"([^"]*\.(?:png|jpe?g|gif|webp))"/gi,
56
57
  /(\S*\.(?:png|jpe?g|gif|webp))/gi,
57
58
  ];
58
- function look(path) {
59
+ function look(path, id) {
60
+ // a protected file is never attached, image or not: a user may list a
61
+ // scan of a passport, and a path in a turn can come from a model's
62
+ // delegate task or a command's output, not only from the person
63
+ if (isProtectedPath(path, id))
64
+ return null;
59
65
  try {
60
66
  const st = statSync(path);
61
67
  if (!st.isFile() || st.size > MAX_IMAGE_BYTES || st.size === 0)
@@ -83,7 +89,8 @@ function look(path) {
83
89
  * standing in the text. Silently dropping it would tell the model about
84
90
  * a file it cannot see, which is worse than telling it nothing.
85
91
  */
86
- export function attachImages(text, files) {
92
+ export function attachImages(text, files, protectedFiles = []) {
93
+ const id = protectedIdentity(protectedFiles);
87
94
  const found = [];
88
95
  const claimed = [];
89
96
  // REL-0152-D16: the `[Image #N]` capsules first. The buffer carries a
@@ -97,7 +104,7 @@ export function attachImages(text, files) {
97
104
  const path = files.get(Number(m[1]));
98
105
  if (path === undefined)
99
106
  continue;
100
- const hit = look(path);
107
+ const hit = look(path, id);
101
108
  if (hit === null)
102
109
  continue;
103
110
  claimed.push({ start: m.index, end: m.index + m[0].length });
@@ -112,7 +119,7 @@ export function attachImages(text, files) {
112
119
  const end = start + m[0].length;
113
120
  if (claimed.some((c) => start < c.end && end > c.start))
114
121
  continue;
115
- const hit = look(path);
122
+ const hit = look(path, id);
116
123
  if (hit === null)
117
124
  continue;
118
125
  claimed.push({ start, end });
package/dist/builtin.d.ts CHANGED
@@ -1,5 +1,17 @@
1
1
  import { type AskUI } from "@vincemakes/kiso-ask-ext";
2
2
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
3
+ /**
4
+ * 0.40.0 (the owner's dogfood): dontAsk never asks, so its tool table never
5
+ * offers ask_user — a model once put four questions to a dontAsk session
6
+ * that could only decline them. A LIVE gate, not a load-time one: the
7
+ * registry reads an extension's tools on every request (registerLive) and
8
+ * the run recomposes the tool table, snippet and guidelines included, so
9
+ * entering dontAsk takes ask_user away from the next run and leaving it
10
+ * brings ask_user back. In dontAsk the table is byte-identical to the pipe
11
+ * path's; each switch in or out costs one prompt-cache break. The decline
12
+ * path (trust-ui) stays for a turn already in flight when the mode changes.
13
+ */
14
+ export declare function offInDontAsk(ext: KisoExtension): KisoExtension;
3
15
  export declare function builtInLayer(user: readonly KisoExtension[], project: readonly KisoExtension[],
4
16
  /** KC3.5: built-in #4 — the ask extension, loaded ONLY when a panel
5
17
  * bridge exists (an interactive TTY). No bridge, no fourth built-in:
package/dist/builtin.js CHANGED
@@ -25,6 +25,27 @@ 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
27
  import createAsk, {} from "@vincemakes/kiso-ask-ext";
28
+ import { getMode } from "./mode.js";
29
+ /**
30
+ * 0.40.0 (the owner's dogfood): dontAsk never asks, so its tool table never
31
+ * offers ask_user — a model once put four questions to a dontAsk session
32
+ * that could only decline them. A LIVE gate, not a load-time one: the
33
+ * registry reads an extension's tools on every request (registerLive) and
34
+ * the run recomposes the tool table, snippet and guidelines included, so
35
+ * entering dontAsk takes ask_user away from the next run and leaving it
36
+ * brings ask_user back. In dontAsk the table is byte-identical to the pipe
37
+ * path's; each switch in or out costs one prompt-cache break. The decline
38
+ * path (trust-ui) stays for a turn already in flight when the mode changes.
39
+ */
40
+ export function offInDontAsk(ext) {
41
+ const tools = ext.tools ?? [];
42
+ return {
43
+ ...ext,
44
+ get tools() {
45
+ return getMode() === "dontAsk" ? [] : tools;
46
+ },
47
+ };
48
+ }
28
49
  export async function builtInLayer(user, project,
29
50
  /** KC3.5: built-in #4 — the ask extension, loaded ONLY when a panel
30
51
  * bridge exists (an interactive TTY). No bridge, no fourth built-in:
@@ -36,7 +57,7 @@ ask,
36
57
  * constructed, so the names have to arrive here — after construction is
37
58
  * too late. Only the config knows them; nothing else can. */
38
59
  secretEnvNames = []) {
39
- const all = await Promise.all([createMcp({ secretEnvNames }), createSkills(), createSubagent(), ...(ask === undefined ? [] : [createAsk(ask)])]);
60
+ const all = await Promise.all([createMcp({ secretEnvNames }), createSkills(), createSubagent(), ...(ask === undefined ? [] : [createAsk(ask).then(offInDontAsk)])]);
40
61
  const shadowed = all.filter((b) => user.some((u) => u.name === b.name));
41
62
  for (const s of shadowed) {
42
63
  console.error(`[extensions] user extension "${s.name}" shadows the built-in — the built-in is not loaded`);
package/dist/chat.d.ts CHANGED
@@ -4,6 +4,7 @@
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 type { SessionRoute } from "./projects.js";
7
8
  import { type RunUsage } from "@vincemakes/kiso-tui";
8
9
  import { type SaferFailure, type SaferOption } from "@vincemakes/kiso-tui";
9
10
  import type { AgentSession, Run } from "@vincemakes/kiso-runtime";
@@ -79,8 +80,10 @@ export declare function microcompactThresholdFor(of?: {
79
80
  readonly baseUrl?: string;
80
81
  }): number;
81
82
  /**
82
- * B area: approximate context ratio — chars/4 of the projected messages vs
83
- * the model window. Marked ~ everywhere it is shown; no counting API.
83
+ * B area: approximate context ratio vs the model window. Marked ~
84
+ * everywhere it is shown. 0.40.0: the context as the last bill measured it
85
+ * (session.contextUsed — the estimate only when no bill describes it);
86
+ * chars/4 alone read the owner's 730k Chinese-heavy context as ~470k.
84
87
  */
85
88
  export declare function estimateCtxRatio(session: AgentSession): number;
86
89
  /** A1a: the ratio the STATUS LINE and the ctx displays show — the request
@@ -98,6 +101,27 @@ export declare function statusModelLabel(session: {
98
101
  };
99
102
  }): string;
100
103
  export declare function displayCtxRatio(session: AgentSession): number;
104
+ /** 0.40.0 item 9: how long a prompt cache is assumed to live. PROVISIONAL:
105
+ * DeepSeek does not publish its TTL (the owner's cache was gone after 27
106
+ * minutes); Anthropic's default is 5 minutes. */
107
+ export declare const COLD_AFTER_MS: number;
108
+ /** 0.40.0 (the owner): the recap's cold-cache wording — only when the turn
109
+ * began at least COLD_AFTER_MS after the last bill AND the cache really
110
+ * missed (a surfaced miss, or under half the prompt from cache). */
111
+ export declare function coldAfter(idleMs: number | undefined, missed: number | null, usage: RunUsage): {
112
+ coldAfterMinutes?: number;
113
+ };
114
+ /**
115
+ * 0.40.0 item 9 — the cold resume. When the last BILL put the context over
116
+ * the microcompact threshold and that bill is older than COLD_AFTER_MS, the
117
+ * next request re-sends the whole prefix uncached. Compacting first turns
118
+ * that one expensive request into a summary call. Anchored only: a session
119
+ * no bill describes has no known size, and no age to call cold.
120
+ */
121
+ export declare function coldResumeOffer(session: AgentSession, now?: number): {
122
+ tokens: number;
123
+ minutes: number;
124
+ } | null;
101
125
  /** A1a: the number the auto-compact decision reads — the pre-A1a estimate,
102
126
  * unchanged this round, named so a gate can pin that it did not move. */
103
127
  export declare function autoCompactRatio(session: AgentSession): number;
@@ -285,6 +309,7 @@ export type ChatEnd = {
285
309
  } | {
286
310
  readonly next: "switch";
287
311
  readonly id: string;
312
+ readonly lines?: readonly string[];
288
313
  }
289
314
  /** §2.5: the caller REBUILDS the agent and re-enters on the same id.
290
315
  * Not "switch": switching keeps the agent and only changes the id,
@@ -293,11 +318,14 @@ export type ChatEnd = {
293
318
  | {
294
319
  readonly next: "reload";
295
320
  readonly id: string;
321
+ readonly lines?: readonly string[];
296
322
  };
297
323
  /** The session-navigation seam main provides: the OTHER sessions'
298
324
  * ids, and (when a dock is up) the existing picker. */
299
325
  export interface ChatNav {
300
326
  readonly sessions: () => readonly string[];
327
+ /** 0.40.0: an id outside this project's folder — refused, or opened where it is */
328
+ readonly route?: (id: string) => SessionRoute | null;
301
329
  readonly pick?: () => Promise<string | null>;
302
330
  }
303
- export declare function chat(session: AgentSession, faux: boolean, input: LineInput, autoCompact?: AutoCompact, nav?: ChatNav): Promise<ChatEnd>;
331
+ export declare function chat(session: AgentSession, faux: boolean, input: LineInput, autoCompact?: AutoCompact, nav?: ChatNav, seed?: readonly string[]): Promise<ChatEnd>;
package/dist/chat.js CHANGED
@@ -6,19 +6,20 @@
6
6
  */
7
7
  import { readFileSync, statSync } from "node:fs";
8
8
  import { escapeTerminal, cacheHitPct, decodeRate, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, verifyOfferView, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
9
- import { askView, deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
- import { canonicalTargetPath, shellProgressPath } from "@vincemakes/kiso-tools-node";
9
+ import { askView, coldResumeLine, coldResumeView, deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
+ import { canonicalTargetPath, isProtectedPath, protectedIdentity, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
+ import { queuedSwitchLines } from "./state.js";
11
12
  import { echoText } from "@vincemakes/kiso-tui-cells/render";
12
13
  import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
13
14
  import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
14
15
  import { dispatch, abortBangCommand } from "./dispatch.js";
15
16
  import { paintWindowTitle } from "./window-title.js";
16
- import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
17
+ import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock, retryOnRow, retryShown, setRetryShown, floorOn, protectedFiles } from "./state.js";
17
18
  import { attachImages } from "./attachments.js";
18
19
  import { lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
19
20
  import { addDontAskAgainRule, askPanel, fixHintFor, pendingAsk, resolveUncertains } from "./trust-ui.js";
20
21
  import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
21
- import { MODES, getMode, setMode } from "./mode.js";
22
+ import { OFFERED_MODES, getMode, setMode } from "./mode.js";
22
23
  /** B area: default context window for the ~ctx estimate (config overridable). */
23
24
  const DEFAULT_CONTEXT_WINDOW = 200_000;
24
25
  /** The in-process fake provider's id, and the window we declare for it.
@@ -134,13 +135,13 @@ export function microcompactThresholdFor(of) {
134
135
  return contextWindowTokens(of) / 2;
135
136
  }
136
137
  /**
137
- * B area: approximate context ratio — chars/4 of the projected messages vs
138
- * the model window. Marked ~ everywhere it is shown; no counting API.
138
+ * B area: approximate context ratio vs the model window. Marked ~
139
+ * everywhere it is shown. 0.40.0: the context as the last bill measured it
140
+ * (session.contextUsed — the estimate only when no bill describes it);
141
+ * chars/4 alone read the owner's 730k Chinese-heavy context as ~470k.
139
142
  */
140
143
  export function estimateCtxRatio(session) {
141
- const projected = session.projected();
142
- const chars = JSON.stringify(projected).length;
143
- return chars / 4 / contextWindowTokens();
144
+ return session.contextUsed() / contextWindowTokens();
144
145
  }
145
146
  /** A1a: the ratio the STATUS LINE and the ctx displays show — the request
146
147
  * budget counting the parts (system prompt, tool table, messages with
@@ -153,6 +154,12 @@ export function estimateCtxRatio(session) {
153
154
  * is the session's own (the durable profile), never the CLI's memory. */
154
155
  export function statusModelLabel(session) {
155
156
  const effort = session.reasoning?.effort;
157
+ // The owner's dogfood, 2026-09-21 (the second pass): the status row under
158
+ // the composer is the MODEL's, and only the model's — a host belongs where
159
+ // a choice is being made (`/model`'s rows, the switch notice) and where the
160
+ // identity is asked for (`/status`), not in a row that has ~40 columns and
161
+ // cut the host to `deepseek/d…ndcode.ai`. Unnecessary at best, misleading at
162
+ // worst: it read as a path, not as an account.
156
163
  return effort !== undefined && effort !== "default" ? `${agentModel} · ${effort}` : agentModel;
157
164
  }
158
165
  export function displayCtxRatio(session) {
@@ -164,8 +171,42 @@ export function displayCtxRatio(session) {
164
171
  const window = knownContextWindow();
165
172
  if (window === null)
166
173
  return Number.NaN;
174
+ // 0.40.0: the last bill is the truth when one describes the context;
175
+ // the parts estimate is for a session no bill describes yet.
176
+ const anchored = session.contextAnchor();
177
+ if (anchored !== undefined)
178
+ return anchored / window;
167
179
  return requestBudget(session.requestParts(), window).ratio;
168
180
  }
181
+ /** 0.40.0 item 9: how long a prompt cache is assumed to live. PROVISIONAL:
182
+ * DeepSeek does not publish its TTL (the owner's cache was gone after 27
183
+ * minutes); Anthropic's default is 5 minutes. */
184
+ export const COLD_AFTER_MS = 5 * 60_000;
185
+ /** 0.40.0 (the owner): the recap's cold-cache wording — only when the turn
186
+ * began at least COLD_AFTER_MS after the last bill AND the cache really
187
+ * missed (a surfaced miss, or under half the prompt from cache). */
188
+ export function coldAfter(idleMs, missed, usage) {
189
+ if (idleMs === undefined || idleMs < COLD_AFTER_MS)
190
+ return {};
191
+ const hit = cacheHitPct(usage);
192
+ return missed !== null || (hit !== null && hit < 50) ? { coldAfterMinutes: Math.round(idleMs / 60_000) } : {};
193
+ }
194
+ /**
195
+ * 0.40.0 item 9 — the cold resume. When the last BILL put the context over
196
+ * the microcompact threshold and that bill is older than COLD_AFTER_MS, the
197
+ * next request re-sends the whole prefix uncached. Compacting first turns
198
+ * that one expensive request into a summary call. Anchored only: a session
199
+ * no bill describes has no known size, and no age to call cold.
200
+ */
201
+ export function coldResumeOffer(session, now = Date.now()) {
202
+ const tokens = session.contextAnchor();
203
+ const at = session.lastUsageAt;
204
+ if (tokens === undefined || at === undefined)
205
+ return null;
206
+ if (tokens <= microcompactThresholdFor() || now - at < COLD_AFTER_MS)
207
+ return null;
208
+ return { tokens, minutes: Math.floor((now - at) / 60_000) };
209
+ }
169
210
  /** A1a: the number the auto-compact decision reads — the pre-A1a estimate,
170
211
  * unchanged this round, named so a gate can pin that it did not move. */
171
212
  export function autoCompactRatio(session) {
@@ -332,6 +373,11 @@ export function turnUsageLedger() {
332
373
  * is gone) — docked only, 200ms rotation between the request and the
333
374
  * first event. */
334
375
  export function startStatusSpinner(onTick) {
376
+ // ADR-0005 Amendment 2: a retry belongs to the run that announced it.
377
+ // Cleared where every run starts and stops — the chat's and the
378
+ // recovery flow's alike — so a stale countdown can never survive into
379
+ // the idle row or the next turn.
380
+ setRetryShown(null);
335
381
  if (!dock.active)
336
382
  return () => { };
337
383
  // v3 §03/§05: the working glyph family ▖▘▝▗, 200ms rotation — the
@@ -340,7 +386,10 @@ export function startStatusSpinner(onTick) {
340
386
  let i = 0;
341
387
  const timer = setInterval(() => onTick(STATUS_GLYPHS[i++ % STATUS_GLYPHS.length]), 200);
342
388
  timer.unref();
343
- return () => clearInterval(timer);
389
+ return () => {
390
+ clearInterval(timer);
391
+ setRetryShown(null);
392
+ };
344
393
  }
345
394
  /**
346
395
  * TUI2-R1 (C) — the shell tailer: the READER half of the progress
@@ -391,6 +440,10 @@ function approvalDiff(name, input) {
391
440
  const path = typeof input.path === "string" ? input.path : "";
392
441
  if (path === "")
393
442
  return null;
443
+ // the tool refuses a protected file whatever the answer; the panel
444
+ // must not print the store on the way there
445
+ if (isProtectedPath(path, protectedIdentity(protectedFiles())))
446
+ return null;
394
447
  let oldContent = null;
395
448
  try {
396
449
  oldContent = readFileSync(path, "utf8");
@@ -745,6 +798,9 @@ submitTurn) {
745
798
  // seconds, usage, ctx left. The turn's WORK is the fold line's, said
746
799
  // once, where the work happened.
747
800
  const turnStart = Date.now();
801
+ // 0.40.0 (the owner): how long the session had been idle when this turn
802
+ // began — the recap names a cold cache instead of a bare fresh figure.
803
+ const idleMs = session.lastUsageAt === undefined ? undefined : turnStart - session.lastUsageAt;
748
804
  // W14: the thinking event carries NO timestamp — the CLI wall-clocks
749
805
  // the thinking window: it opens at the first thinking event and closes
750
806
  // at the first non-thinking event (the fold needs the seconds).
@@ -763,11 +819,21 @@ submitTurn) {
763
819
  try {
764
820
  for await (const ev of run) {
765
821
  last = ev;
822
+ // ADR-0005 Amendment 2: an event from the run means the retried
823
+ // attempt got through — the row stops saying it is waiting.
824
+ if (retryShown !== null)
825
+ setRetryShown(null);
766
826
  // LT2B-F1: a turn count is not evidence of a loop. Keep consuming
767
827
  // healthy runs without a periodic presence check; explicit cancellation,
768
828
  // the stream watchdog and the repeated-failure breaker remain active.
769
829
  // W22-R1: the ledger sees every event; a stop is what ends a call.
770
830
  ledger.observe(ev);
831
+ // ADR-0055 Amendment 1 (A1b): a compaction inside the run says so, once.
832
+ if (ev.type === "summarized" || ev.type === "microcompacted") {
833
+ const r = displayCtxRatio(session);
834
+ const ctx = Number.isFinite(r) ? ` · ctx now ~${Math.round(r * 100)}% used` : "";
835
+ body.notice(ev.type === "summarized" ? `✦ compacted mid-run — the conversation before this point is a summary now${ctx}` : `✦ pruned old tool output mid-run${ctx}`);
836
+ }
771
837
  if (ev.type !== "thinking") {
772
838
  if (thinkingSince !== null) {
773
839
  thoughtSeconds += (Date.now() - thinkingSince) / 1000;
@@ -800,6 +866,12 @@ submitTurn) {
800
866
  body.notice(` ${typeof ev.content === "string" ? ev.content : ""}`);
801
867
  break;
802
868
  }
869
+ // 0.40.0: a skill turn's chip is the line the person TYPED — the
870
+ // SKILL.md body is what the model read, not what they said.
871
+ if (ev.via !== undefined) {
872
+ body.userLine(ev.via.line);
873
+ break;
874
+ }
803
875
  // DC-60: a turn that carries an image is a content ARRAY — its words
804
876
  // still echo, the image as a mark; an empty chip was the bug.
805
877
  body.userLine(echoText(ev.content));
@@ -912,8 +984,17 @@ submitTurn) {
912
984
  // - Yes+amend → approve(true), the words ride the NEXT turn;
913
985
  // - esc → cancel, the conservative denial.
914
986
  const name = ev.name;
915
- body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
916
987
  const decisionId = ev.decisionId;
988
+ // Launch-weekend plan §2 — dontAsk: the chain's final ASK is
989
+ // denied HERE, at the one place kiso asks, so every allow the
990
+ // chain gave still stands (a tier deny would outrank them). The
991
+ // reason is the tool result: the model sees why and goes on.
992
+ if (getMode() === "dontAsk") {
993
+ body.notice(`[dontAsk] ${escapeTerminal(name)} would ask — denied`);
994
+ await session.approve(decisionId, false, `dontAsk: ${name} needs a human's approval, and this session never asks — denied`);
995
+ break;
996
+ }
997
+ body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
917
998
  // TUI2-R3v2 ③: the on-demand alternatives provider. It is built
918
999
  // per approval and captured by the panel; it fires ONLY if the
919
1000
  // human presses option 3, which is the whole zero-ambient-rent
@@ -1054,6 +1135,10 @@ submitTurn) {
1054
1135
  // R-C item 4: only an above-floor miss is surfaced —
1055
1136
  // the recap gains "· miss N" on the cache segment.
1056
1137
  ...(missed !== null ? { missed } : {}),
1138
+ // Cold: idle past the cache's assumed life (the #77
1139
+ // constant, provisional) AND the turn did re-send a
1140
+ // prefix uncached — the time alone is not evidence.
1141
+ ...(coldAfter(idleMs, missed, turnUsage() ?? UNKNOWN_USAGE)),
1057
1142
  ctxLeftPct: Number.isFinite(ratio) ? (1 - ratio) * 100 : null,
1058
1143
  // W19: under plan the recap becomes the way-forward row
1059
1144
  // (the /mode hints are the mode's exits).
@@ -1086,7 +1171,7 @@ submitTurn) {
1086
1171
  }
1087
1172
  return last;
1088
1173
  }
1089
- export async function chat(session, faux, input, autoCompact, nav) {
1174
+ export async function chat(session, faux, input, autoCompact, nav, seed) {
1090
1175
  // the switch directive — set once by dispatch's /clear or /resume,
1091
1176
  // resolved through the end signal so the final awaits still run
1092
1177
  let switchTo = null;
@@ -1116,7 +1201,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1116
1201
  console.log("\n[exit requested]");
1117
1202
  input.close();
1118
1203
  };
1119
- const turn = (text, seedSource) => new Promise((resolve, reject) => {
1204
+ const turn = (text, seedSource, via) => new Promise((resolve, reject) => {
1120
1205
  queued = Math.max(0, queued - 1); // a queued turn starts
1121
1206
  // REL-0152-D11: a turn that names an image file carries it. The
1122
1207
  // scan returns the STRING unchanged when it finds nothing, so a
@@ -1126,8 +1211,12 @@ export async function chat(session, faux, input, autoCompact, nav) {
1126
1211
  // REL-0152-D16: the capsules' files come from the editor, which
1127
1212
  // is the only thing that knows which number stands for which
1128
1213
  // screenshot.
1129
- const content = seedSource !== undefined ? text : attachImages(text, input.attachments?.());
1130
- const run = seedSource !== undefined ? session.run(content, { source: seedSource }) : session.run(content);
1214
+ //
1215
+ // 0.40.0: a skill turn is not scanned either — its text is a
1216
+ // SKILL.md body, and a body that mentions `diagram.png` must not
1217
+ // attach a file from the workspace the person never pointed at.
1218
+ const content = seedSource !== undefined || via !== undefined ? text : attachImages(text, input.attachments?.(), protectedFiles());
1219
+ const run = seedSource !== undefined ? session.run(content, { source: seedSource }) : via !== undefined ? session.run(content, { via }) : session.run(content);
1131
1220
  currentRun = run;
1132
1221
  turnNo += 1;
1133
1222
  const myTurn = turnNo;
@@ -1278,11 +1367,15 @@ export async function chat(session, faux, input, autoCompact, nav) {
1278
1367
  // BEHIND the correction, in their original order. Ephemeral
1279
1368
  // reordering of ephemeral state; the durable log still just records
1280
1369
  // what ran, in the order it ran.
1281
- const jumped = pendingTurns.map((s) => s.line);
1370
+ // A slot re-enters with its CONTENT and its `via`, never its display
1371
+ // line: a queued skill re-submitted as `/review x` would reach the
1372
+ // model as the literal text of the command.
1373
+ const jumped = pendingTurns.map((s) => ({ content: s.content, via: s.via }));
1282
1374
  for (let i = jumped.length; i > 0; i -= 1)
1283
1375
  popQueue();
1284
- for (const text of [line, ...jumped])
1285
- submitTurn(text);
1376
+ submitTurn(line);
1377
+ for (const j of jumped)
1378
+ submitTurn(j.content, j.via);
1286
1379
  });
1287
1380
  // round 5 (P1-11): the PERSISTENT line listener is installed BEFORE the
1288
1381
  // startup recovery — a cancelled question's re-emitted "line" needs a
@@ -1293,7 +1386,15 @@ export async function chat(session, faux, input, autoCompact, nav) {
1293
1386
  // REPL is ready (they are never dropped).
1294
1387
  const chainRef = { current: Promise.resolve() };
1295
1388
  let replReady = false;
1296
- const queuedLines = [];
1389
+ const queuedLines = [...(seed ?? []), ...queuedSwitchLines.splice(0)];
1390
+ // DC-57 (the owner's ruling, 2026-09-21): once the session is LEAVING —
1391
+ // a switch or a reload has been requested — a line that arrives belongs
1392
+ // to the session the person asked for, not to the one they are leaving.
1393
+ // One read can carry several lines (a paste, a pipe, a scripted
1394
+ // driver), and the departing instance used to dispatch them; they are
1395
+ // queued here and replayed by the NEXT chat() entry, exactly as
1396
+ // pre-ready lines are replayed by this one.
1397
+ let leaving = false;
1297
1398
  // B area: user-turn counter for the status line. /last and /think read
1298
1399
  // the body (the ToolCell / ThinkingCell final states).
1299
1400
  let turnNo = 0;
@@ -1304,6 +1405,9 @@ export async function chat(session, faux, input, autoCompact, nav) {
1304
1405
  // read (the dock renders the lines, the editor pops the last one).
1305
1406
  // A slot leaves the queue when its turn STARTS or when the user
1306
1407
  // pops it (cancelled — the chain segment skips it).
1408
+ // 0.40.0: `line` is what the chip shows and what a pop hands back to
1409
+ // the editor — for a skill, the line the person TYPED; `content` is
1410
+ // what the turn submits (the skill's body and args).
1307
1411
  const pendingTurns = [];
1308
1412
  // TV-1B — the offer memory: session-local BY DESIGN (a dead process's
1309
1413
  // "not now" should not silence a live one; resume re-offers once,
@@ -1344,8 +1448,12 @@ export async function chat(session, faux, input, autoCompact, nav) {
1344
1448
  // KC2 §5: the STATE (the glyph, the run's start, the usage, the dock)
1345
1449
  // stays here; the ROW's text is the tui's status formatter.
1346
1450
  const paintRunning = () => {
1451
+ // ADR-0005 Amendment 2: the running row gets the idle row's budget
1452
+ // (DF-0330-F1). A pending retry makes it thirty columns longer, and
1453
+ // composed blind it would be cut from the END — the context figure,
1454
+ // a fact, going before the gesture hints.
1347
1455
  if (dock.active)
1348
- dock.setStatus(runningStatus(runGlyph, runStart, runUsage.out, displayCtxRatio(session), lastTokPerSec));
1456
+ dock.setStatus(runningStatus(runGlyph, runStart, runUsage.out, displayCtxRatio(session), lastTokPerSec, rowWidth(), retryOnRow()));
1349
1457
  };
1350
1458
  // W19: under plan the idle row makes the posture unmistakable — the W4
1351
1459
  // parentheses idiom names the read-only constraint. The tier is the
@@ -1364,7 +1472,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1364
1472
  // DF-0330-F1: the row's budget. Without it the row is composed
1365
1473
  // blind and invariant ① cuts whatever sits last — which is how
1366
1474
  // a measured rate went missing at 100 columns.
1367
- rowWidth()));
1475
+ rowWidth(), !floorOn));
1368
1476
  };
1369
1477
  // TUI2-R2 ⑥ — the BOOT status line. The row is the product's one
1370
1478
  // persistent claim about itself (the tier, how to change it, the model,
@@ -1408,8 +1516,8 @@ export async function chat(session, faux, input, autoCompact, nav) {
1408
1516
  return;
1409
1517
  spentUsd = (spentUsd ?? 0) + usd;
1410
1518
  };
1411
- const submitTurn = (line) => {
1412
- const slot = { line, cancelled: false };
1519
+ const submitTurn = (line, via) => {
1520
+ const slot = { line: via?.line ?? line, content: line, ...(via !== undefined ? { via } : {}), cancelled: false };
1413
1521
  pendingTurns.push(slot);
1414
1522
  queued += 1;
1415
1523
  chainRef.current = chainRef.current.then(async () => {
@@ -1433,7 +1541,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1433
1541
  if (session.uncertainExecutions().length > 0)
1434
1542
  await resolveUncertains(session, input, () => cancelled);
1435
1543
  if (session.uncertainExecutions().length === 0)
1436
- return turn(line);
1544
+ return turn(line, undefined, via);
1437
1545
  // The human declined (round 10: a cancelled ask records NOTHING —
1438
1546
  // the execution stays uncertain and durable), so the turn does not
1439
1547
  // start. It is never swallowed in silence: the held text is
@@ -1472,15 +1580,22 @@ export async function chat(session, faux, input, autoCompact, nav) {
1472
1580
  // the /resume+/clear mini-spec: the switch directive and the
1473
1581
  // session-navigation seam (absent nav = the commands degrade to
1474
1582
  // an honest refusal in dispatch)
1583
+ // DC-57: the dispatch edge asks this before it answers a line, so the
1584
+ // decision is made when the line's segment runs — not when its bytes
1585
+ // arrived, which can be before the switch was even requested.
1586
+ leaving: () => leaving,
1475
1587
  requestSwitch: (id) => {
1476
1588
  switchTo = id;
1589
+ leaving = true;
1477
1590
  resolveEnd();
1478
1591
  },
1479
1592
  requestReload: () => {
1480
1593
  reloadReq = true;
1594
+ leaving = true;
1481
1595
  resolveEnd();
1482
1596
  },
1483
1597
  sessions: () => nav?.sessions() ?? [],
1598
+ ...(nav?.route !== undefined ? { route: nav.route } : {}),
1484
1599
  ...(nav?.pick !== undefined ? { pickSession: nav.pick } : {}),
1485
1600
  };
1486
1601
  // the ergonomics batch C8: the auto-compact check — the /compact FULL path via the
@@ -1509,7 +1624,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1509
1624
  dispatch(line, dispatchCtx);
1510
1625
  };
1511
1626
  input.onLine((line) => {
1512
- if (!replReady) {
1627
+ if (!replReady || leaving) {
1513
1628
  queuedLines.push(line);
1514
1629
  return;
1515
1630
  }
@@ -1525,10 +1640,11 @@ export async function chat(session, faux, input, autoCompact, nav) {
1525
1640
  // they are the same sentinel: one implementation, one behaviour.
1526
1641
  input.onCopy(() => dispatch("\x18copy", dispatchCtx));
1527
1642
  // R3a — Shift+Tab: the approval-tier cycle (the /mode ring, in the
1528
- // MODES order). The switch is the SAME live-extension flip /mode
1529
- // performs; the status row repaints at once with a one-line notice.
1643
+ // OFFERED order — a session started in `manual` steps to the first).
1644
+ // The switch is the SAME live-extension flip /mode performs; the status
1645
+ // row repaints at once with a one-line notice.
1530
1646
  input.onModeCycle?.(() => {
1531
- const next = MODES[(MODES.indexOf(getMode()) + 1) % MODES.length];
1647
+ const next = OFFERED_MODES[(OFFERED_MODES.indexOf(getMode()) + 1) % OFFERED_MODES.length];
1532
1648
  setMode(next);
1533
1649
  paintIdle();
1534
1650
  body.notice(`mode → ${next} (shift+tab cycles)`);
@@ -1539,6 +1655,29 @@ export async function chat(session, faux, input, autoCompact, nav) {
1539
1655
  // round 8: the startup resume is bound to currentRun — Ctrl+C during it
1540
1656
  // aborts the recovery, exactly like the interactive turns.
1541
1657
  await resolveUncertains(session, input, () => cancelled);
1658
+ // 0.40.0 item 9: a big session whose cache has gone cold compacts BEFORE
1659
+ // its first request, on the person's word — or without asking in
1660
+ // dontAsk, where compacting is not an approval. A piped session is left
1661
+ // to the auto policy, which fires above the hard tier on its own. Lines
1662
+ // typed meanwhile queue behind the compaction.
1663
+ //
1664
+ // The owner's dogfood: asked BEFORE the recovery run. A session whose
1665
+ // last run was cut resumes that run at once, and the resume's first
1666
+ // request is the whole cold prefix — asked afterwards, the question is
1667
+ // about a bill already paid (and the in-run tiers have usually shrunk the
1668
+ // context by then, so it is never asked at all). The compaction settles
1669
+ // before the run resumes: the run continues on the compacted projection.
1670
+ const cold = !cancelled && process.stdin.isTTY ? coldResumeOffer(session) : null;
1671
+ if (cold !== null) {
1672
+ if (getMode() === "dontAsk") {
1673
+ body.notice(`[dontAsk] ${coldResumeLine(cold.tokens, cold.minutes)} — compacting first`);
1674
+ dispatch("/compact", dispatchCtx);
1675
+ }
1676
+ else if ((await askPanel(input, coldResumeView(cold.tokens, cold.minutes))).action === "allow") {
1677
+ dispatch("/compact", dispatchCtx);
1678
+ }
1679
+ await chainRef.current;
1680
+ }
1542
1681
  if (!cancelled) {
1543
1682
  const recoveryRun = session.resume();
1544
1683
  currentRun = recoveryRun;
@@ -1590,8 +1729,8 @@ export async function chat(session, faux, input, autoCompact, nav) {
1590
1729
  // enough: the /compact segment appends nothing of its own.
1591
1730
  await chainRef.current;
1592
1731
  if (switchTo !== null)
1593
- return { next: "switch", id: switchTo };
1732
+ return { next: "switch", id: switchTo, lines: queuedLines.slice() };
1594
1733
  // a switch beats a reload: /resume and /clear are going somewhere else,
1595
1734
  // and the agent they land on is rebuilt by the caller either way.
1596
- return reloadReq ? { next: "reload", id: session.id } : { next: "exit" };
1735
+ return reloadReq ? { next: "reload", id: session.id, lines: queuedLines.slice() } : { next: "exit" };
1597
1736
  }
package/dist/config.d.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * "anthropic" | "openai-responses")
13
13
  * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
14
14
  * baseUrl?: string, model: string, apiKeyEnv: string } }
15
- * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
+ * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"|"dontAsk"
16
16
  * contextWindow?: number — tokens
17
17
  * autoCompact?: { thresholdRatio: number } — 0<r<1; default off
18
18
  * projectTrust?: "ask" | "never" — no "always" (the ruling)
@@ -82,6 +82,16 @@ export interface KisoConfig {
82
82
  * project file is a LOUD error rather than a silent win. `KISO_THEME`
83
83
  * still outranks it — the environment is the more local answer. */
84
84
  readonly theme?: "dark" | "light";
85
+ /** 0.40.0: the catastrophe floor (floor.ts). Default on. USER-LEVEL
86
+ * ONLY, and louder than theme about it: a repository that could lower
87
+ * the floor would be the one thing the floor exists to stop. */
88
+ readonly floor?: "catastrophe" | "off";
89
+ /** kiso never serves its own credential store to a model, and these
90
+ * files join it: no tool reads, writes or searches them, and no shell
91
+ * line may name them (protected-shell.ts). Absolute, or `~/…`. USER-
92
+ * LEVEL ONLY, as loud as floor: a project must never be able to change
93
+ * what kiso guards. */
94
+ readonly protectedPaths?: readonly string[];
85
95
  /** DT-1a: named acceptance checks a delegated task may reference —
86
96
  * user-authored (or trust-gated project) commands, run by the PARENT
87
97
  * in the child's worktree. A model never supplies a command; it names