@vincemakes/kiso-code 0.39.2 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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;
@@ -298,6 +322,8 @@ export type ChatEnd = {
298
322
  * ids, and (when a dock is up) the existing picker. */
299
323
  export interface ChatNav {
300
324
  readonly sessions: () => readonly string[];
325
+ /** 0.40.0: an id outside this project's folder — refused, or opened where it is */
326
+ readonly route?: (id: string) => SessionRoute | null;
301
327
  readonly pick?: () => Promise<string | null>;
302
328
  }
303
329
  export declare function chat(session: AgentSession, faux: boolean, input: LineInput, autoCompact?: AutoCompact, nav?: ChatNav): Promise<ChatEnd>;
package/dist/chat.js CHANGED
@@ -6,19 +6,19 @@
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
11
  import { echoText } from "@vincemakes/kiso-tui-cells/render";
12
12
  import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
13
13
  import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
14
14
  import { dispatch, abortBangCommand } from "./dispatch.js";
15
15
  import { paintWindowTitle } from "./window-title.js";
16
- import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
16
+ import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock, retryOnRow, retryShown, setRetryShown, floorOn, protectedFiles } from "./state.js";
17
17
  import { attachImages } from "./attachments.js";
18
18
  import { lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
19
19
  import { addDontAskAgainRule, askPanel, fixHintFor, pendingAsk, resolveUncertains } from "./trust-ui.js";
20
20
  import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
21
- import { MODES, getMode, setMode } from "./mode.js";
21
+ import { OFFERED_MODES, getMode, setMode } from "./mode.js";
22
22
  /** B area: default context window for the ~ctx estimate (config overridable). */
23
23
  const DEFAULT_CONTEXT_WINDOW = 200_000;
24
24
  /** The in-process fake provider's id, and the window we declare for it.
@@ -134,13 +134,13 @@ export function microcompactThresholdFor(of) {
134
134
  return contextWindowTokens(of) / 2;
135
135
  }
136
136
  /**
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.
137
+ * B area: approximate context ratio vs the model window. Marked ~
138
+ * everywhere it is shown. 0.40.0: the context as the last bill measured it
139
+ * (session.contextUsed — the estimate only when no bill describes it);
140
+ * chars/4 alone read the owner's 730k Chinese-heavy context as ~470k.
139
141
  */
140
142
  export function estimateCtxRatio(session) {
141
- const projected = session.projected();
142
- const chars = JSON.stringify(projected).length;
143
- return chars / 4 / contextWindowTokens();
143
+ return session.contextUsed() / contextWindowTokens();
144
144
  }
145
145
  /** A1a: the ratio the STATUS LINE and the ctx displays show — the request
146
146
  * budget counting the parts (system prompt, tool table, messages with
@@ -164,8 +164,42 @@ export function displayCtxRatio(session) {
164
164
  const window = knownContextWindow();
165
165
  if (window === null)
166
166
  return Number.NaN;
167
+ // 0.40.0: the last bill is the truth when one describes the context;
168
+ // the parts estimate is for a session no bill describes yet.
169
+ const anchored = session.contextAnchor();
170
+ if (anchored !== undefined)
171
+ return anchored / window;
167
172
  return requestBudget(session.requestParts(), window).ratio;
168
173
  }
174
+ /** 0.40.0 item 9: how long a prompt cache is assumed to live. PROVISIONAL:
175
+ * DeepSeek does not publish its TTL (the owner's cache was gone after 27
176
+ * minutes); Anthropic's default is 5 minutes. */
177
+ export const COLD_AFTER_MS = 5 * 60_000;
178
+ /** 0.40.0 (the owner): the recap's cold-cache wording — only when the turn
179
+ * began at least COLD_AFTER_MS after the last bill AND the cache really
180
+ * missed (a surfaced miss, or under half the prompt from cache). */
181
+ export function coldAfter(idleMs, missed, usage) {
182
+ if (idleMs === undefined || idleMs < COLD_AFTER_MS)
183
+ return {};
184
+ const hit = cacheHitPct(usage);
185
+ return missed !== null || (hit !== null && hit < 50) ? { coldAfterMinutes: Math.round(idleMs / 60_000) } : {};
186
+ }
187
+ /**
188
+ * 0.40.0 item 9 — the cold resume. When the last BILL put the context over
189
+ * the microcompact threshold and that bill is older than COLD_AFTER_MS, the
190
+ * next request re-sends the whole prefix uncached. Compacting first turns
191
+ * that one expensive request into a summary call. Anchored only: a session
192
+ * no bill describes has no known size, and no age to call cold.
193
+ */
194
+ export function coldResumeOffer(session, now = Date.now()) {
195
+ const tokens = session.contextAnchor();
196
+ const at = session.lastUsageAt;
197
+ if (tokens === undefined || at === undefined)
198
+ return null;
199
+ if (tokens <= microcompactThresholdFor() || now - at < COLD_AFTER_MS)
200
+ return null;
201
+ return { tokens, minutes: Math.floor((now - at) / 60_000) };
202
+ }
169
203
  /** A1a: the number the auto-compact decision reads — the pre-A1a estimate,
170
204
  * unchanged this round, named so a gate can pin that it did not move. */
171
205
  export function autoCompactRatio(session) {
@@ -332,6 +366,11 @@ export function turnUsageLedger() {
332
366
  * is gone) — docked only, 200ms rotation between the request and the
333
367
  * first event. */
334
368
  export function startStatusSpinner(onTick) {
369
+ // ADR-0005 Amendment 2: a retry belongs to the run that announced it.
370
+ // Cleared where every run starts and stops — the chat's and the
371
+ // recovery flow's alike — so a stale countdown can never survive into
372
+ // the idle row or the next turn.
373
+ setRetryShown(null);
335
374
  if (!dock.active)
336
375
  return () => { };
337
376
  // v3 §03/§05: the working glyph family ▖▘▝▗, 200ms rotation — the
@@ -340,7 +379,10 @@ export function startStatusSpinner(onTick) {
340
379
  let i = 0;
341
380
  const timer = setInterval(() => onTick(STATUS_GLYPHS[i++ % STATUS_GLYPHS.length]), 200);
342
381
  timer.unref();
343
- return () => clearInterval(timer);
382
+ return () => {
383
+ clearInterval(timer);
384
+ setRetryShown(null);
385
+ };
344
386
  }
345
387
  /**
346
388
  * TUI2-R1 (C) — the shell tailer: the READER half of the progress
@@ -391,6 +433,10 @@ function approvalDiff(name, input) {
391
433
  const path = typeof input.path === "string" ? input.path : "";
392
434
  if (path === "")
393
435
  return null;
436
+ // the tool refuses a protected file whatever the answer; the panel
437
+ // must not print the store on the way there
438
+ if (isProtectedPath(path, protectedIdentity(protectedFiles())))
439
+ return null;
394
440
  let oldContent = null;
395
441
  try {
396
442
  oldContent = readFileSync(path, "utf8");
@@ -745,6 +791,9 @@ submitTurn) {
745
791
  // seconds, usage, ctx left. The turn's WORK is the fold line's, said
746
792
  // once, where the work happened.
747
793
  const turnStart = Date.now();
794
+ // 0.40.0 (the owner): how long the session had been idle when this turn
795
+ // began — the recap names a cold cache instead of a bare fresh figure.
796
+ const idleMs = session.lastUsageAt === undefined ? undefined : turnStart - session.lastUsageAt;
748
797
  // W14: the thinking event carries NO timestamp — the CLI wall-clocks
749
798
  // the thinking window: it opens at the first thinking event and closes
750
799
  // at the first non-thinking event (the fold needs the seconds).
@@ -763,11 +812,21 @@ submitTurn) {
763
812
  try {
764
813
  for await (const ev of run) {
765
814
  last = ev;
815
+ // ADR-0005 Amendment 2: an event from the run means the retried
816
+ // attempt got through — the row stops saying it is waiting.
817
+ if (retryShown !== null)
818
+ setRetryShown(null);
766
819
  // LT2B-F1: a turn count is not evidence of a loop. Keep consuming
767
820
  // healthy runs without a periodic presence check; explicit cancellation,
768
821
  // the stream watchdog and the repeated-failure breaker remain active.
769
822
  // W22-R1: the ledger sees every event; a stop is what ends a call.
770
823
  ledger.observe(ev);
824
+ // ADR-0055 Amendment 1 (A1b): a compaction inside the run says so, once.
825
+ if (ev.type === "summarized" || ev.type === "microcompacted") {
826
+ const r = displayCtxRatio(session);
827
+ const ctx = Number.isFinite(r) ? ` · ctx now ~${Math.round(r * 100)}% used` : "";
828
+ 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}`);
829
+ }
771
830
  if (ev.type !== "thinking") {
772
831
  if (thinkingSince !== null) {
773
832
  thoughtSeconds += (Date.now() - thinkingSince) / 1000;
@@ -800,6 +859,12 @@ submitTurn) {
800
859
  body.notice(` ${typeof ev.content === "string" ? ev.content : ""}`);
801
860
  break;
802
861
  }
862
+ // 0.40.0: a skill turn's chip is the line the person TYPED — the
863
+ // SKILL.md body is what the model read, not what they said.
864
+ if (ev.via !== undefined) {
865
+ body.userLine(ev.via.line);
866
+ break;
867
+ }
803
868
  // DC-60: a turn that carries an image is a content ARRAY — its words
804
869
  // still echo, the image as a mark; an empty chip was the bug.
805
870
  body.userLine(echoText(ev.content));
@@ -912,8 +977,17 @@ submitTurn) {
912
977
  // - Yes+amend → approve(true), the words ride the NEXT turn;
913
978
  // - esc → cancel, the conservative denial.
914
979
  const name = ev.name;
915
- body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
916
980
  const decisionId = ev.decisionId;
981
+ // Launch-weekend plan §2 — dontAsk: the chain's final ASK is
982
+ // denied HERE, at the one place kiso asks, so every allow the
983
+ // chain gave still stands (a tier deny would outrank them). The
984
+ // reason is the tool result: the model sees why and goes on.
985
+ if (getMode() === "dontAsk") {
986
+ body.notice(`[dontAsk] ${escapeTerminal(name)} would ask — denied`);
987
+ await session.approve(decisionId, false, `dontAsk: ${name} needs a human's approval, and this session never asks — denied`);
988
+ break;
989
+ }
990
+ body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
917
991
  // TUI2-R3v2 ③: the on-demand alternatives provider. It is built
918
992
  // per approval and captured by the panel; it fires ONLY if the
919
993
  // human presses option 3, which is the whole zero-ambient-rent
@@ -1054,6 +1128,10 @@ submitTurn) {
1054
1128
  // R-C item 4: only an above-floor miss is surfaced —
1055
1129
  // the recap gains "· miss N" on the cache segment.
1056
1130
  ...(missed !== null ? { missed } : {}),
1131
+ // Cold: idle past the cache's assumed life (the #77
1132
+ // constant, provisional) AND the turn did re-send a
1133
+ // prefix uncached — the time alone is not evidence.
1134
+ ...(coldAfter(idleMs, missed, turnUsage() ?? UNKNOWN_USAGE)),
1057
1135
  ctxLeftPct: Number.isFinite(ratio) ? (1 - ratio) * 100 : null,
1058
1136
  // W19: under plan the recap becomes the way-forward row
1059
1137
  // (the /mode hints are the mode's exits).
@@ -1116,7 +1194,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1116
1194
  console.log("\n[exit requested]");
1117
1195
  input.close();
1118
1196
  };
1119
- const turn = (text, seedSource) => new Promise((resolve, reject) => {
1197
+ const turn = (text, seedSource, via) => new Promise((resolve, reject) => {
1120
1198
  queued = Math.max(0, queued - 1); // a queued turn starts
1121
1199
  // REL-0152-D11: a turn that names an image file carries it. The
1122
1200
  // scan returns the STRING unchanged when it finds nothing, so a
@@ -1126,8 +1204,12 @@ export async function chat(session, faux, input, autoCompact, nav) {
1126
1204
  // REL-0152-D16: the capsules' files come from the editor, which
1127
1205
  // is the only thing that knows which number stands for which
1128
1206
  // screenshot.
1129
- const content = seedSource !== undefined ? text : attachImages(text, input.attachments?.());
1130
- const run = seedSource !== undefined ? session.run(content, { source: seedSource }) : session.run(content);
1207
+ //
1208
+ // 0.40.0: a skill turn is not scanned either — its text is a
1209
+ // SKILL.md body, and a body that mentions `diagram.png` must not
1210
+ // attach a file from the workspace the person never pointed at.
1211
+ const content = seedSource !== undefined || via !== undefined ? text : attachImages(text, input.attachments?.(), protectedFiles());
1212
+ const run = seedSource !== undefined ? session.run(content, { source: seedSource }) : via !== undefined ? session.run(content, { via }) : session.run(content);
1131
1213
  currentRun = run;
1132
1214
  turnNo += 1;
1133
1215
  const myTurn = turnNo;
@@ -1278,11 +1360,15 @@ export async function chat(session, faux, input, autoCompact, nav) {
1278
1360
  // BEHIND the correction, in their original order. Ephemeral
1279
1361
  // reordering of ephemeral state; the durable log still just records
1280
1362
  // what ran, in the order it ran.
1281
- const jumped = pendingTurns.map((s) => s.line);
1363
+ // A slot re-enters with its CONTENT and its `via`, never its display
1364
+ // line: a queued skill re-submitted as `/review x` would reach the
1365
+ // model as the literal text of the command.
1366
+ const jumped = pendingTurns.map((s) => ({ content: s.content, via: s.via }));
1282
1367
  for (let i = jumped.length; i > 0; i -= 1)
1283
1368
  popQueue();
1284
- for (const text of [line, ...jumped])
1285
- submitTurn(text);
1369
+ submitTurn(line);
1370
+ for (const j of jumped)
1371
+ submitTurn(j.content, j.via);
1286
1372
  });
1287
1373
  // round 5 (P1-11): the PERSISTENT line listener is installed BEFORE the
1288
1374
  // startup recovery — a cancelled question's re-emitted "line" needs a
@@ -1304,6 +1390,9 @@ export async function chat(session, faux, input, autoCompact, nav) {
1304
1390
  // read (the dock renders the lines, the editor pops the last one).
1305
1391
  // A slot leaves the queue when its turn STARTS or when the user
1306
1392
  // pops it (cancelled — the chain segment skips it).
1393
+ // 0.40.0: `line` is what the chip shows and what a pop hands back to
1394
+ // the editor — for a skill, the line the person TYPED; `content` is
1395
+ // what the turn submits (the skill's body and args).
1307
1396
  const pendingTurns = [];
1308
1397
  // TV-1B — the offer memory: session-local BY DESIGN (a dead process's
1309
1398
  // "not now" should not silence a live one; resume re-offers once,
@@ -1344,8 +1433,12 @@ export async function chat(session, faux, input, autoCompact, nav) {
1344
1433
  // KC2 §5: the STATE (the glyph, the run's start, the usage, the dock)
1345
1434
  // stays here; the ROW's text is the tui's status formatter.
1346
1435
  const paintRunning = () => {
1436
+ // ADR-0005 Amendment 2: the running row gets the idle row's budget
1437
+ // (DF-0330-F1). A pending retry makes it thirty columns longer, and
1438
+ // composed blind it would be cut from the END — the context figure,
1439
+ // a fact, going before the gesture hints.
1347
1440
  if (dock.active)
1348
- dock.setStatus(runningStatus(runGlyph, runStart, runUsage.out, displayCtxRatio(session), lastTokPerSec));
1441
+ dock.setStatus(runningStatus(runGlyph, runStart, runUsage.out, displayCtxRatio(session), lastTokPerSec, rowWidth(), retryOnRow()));
1349
1442
  };
1350
1443
  // W19: under plan the idle row makes the posture unmistakable — the W4
1351
1444
  // parentheses idiom names the read-only constraint. The tier is the
@@ -1364,7 +1457,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1364
1457
  // DF-0330-F1: the row's budget. Without it the row is composed
1365
1458
  // blind and invariant ① cuts whatever sits last — which is how
1366
1459
  // a measured rate went missing at 100 columns.
1367
- rowWidth()));
1460
+ rowWidth(), !floorOn));
1368
1461
  };
1369
1462
  // TUI2-R2 ⑥ — the BOOT status line. The row is the product's one
1370
1463
  // persistent claim about itself (the tier, how to change it, the model,
@@ -1408,8 +1501,8 @@ export async function chat(session, faux, input, autoCompact, nav) {
1408
1501
  return;
1409
1502
  spentUsd = (spentUsd ?? 0) + usd;
1410
1503
  };
1411
- const submitTurn = (line) => {
1412
- const slot = { line, cancelled: false };
1504
+ const submitTurn = (line, via) => {
1505
+ const slot = { line: via?.line ?? line, content: line, ...(via !== undefined ? { via } : {}), cancelled: false };
1413
1506
  pendingTurns.push(slot);
1414
1507
  queued += 1;
1415
1508
  chainRef.current = chainRef.current.then(async () => {
@@ -1433,7 +1526,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1433
1526
  if (session.uncertainExecutions().length > 0)
1434
1527
  await resolveUncertains(session, input, () => cancelled);
1435
1528
  if (session.uncertainExecutions().length === 0)
1436
- return turn(line);
1529
+ return turn(line, undefined, via);
1437
1530
  // The human declined (round 10: a cancelled ask records NOTHING —
1438
1531
  // the execution stays uncertain and durable), so the turn does not
1439
1532
  // start. It is never swallowed in silence: the held text is
@@ -1481,6 +1574,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1481
1574
  resolveEnd();
1482
1575
  },
1483
1576
  sessions: () => nav?.sessions() ?? [],
1577
+ ...(nav?.route !== undefined ? { route: nav.route } : {}),
1484
1578
  ...(nav?.pick !== undefined ? { pickSession: nav.pick } : {}),
1485
1579
  };
1486
1580
  // the ergonomics batch C8: the auto-compact check — the /compact FULL path via the
@@ -1525,10 +1619,11 @@ export async function chat(session, faux, input, autoCompact, nav) {
1525
1619
  // they are the same sentinel: one implementation, one behaviour.
1526
1620
  input.onCopy(() => dispatch("\x18copy", dispatchCtx));
1527
1621
  // 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.
1622
+ // OFFERED order — a session started in `manual` steps to the first).
1623
+ // The switch is the SAME live-extension flip /mode performs; the status
1624
+ // row repaints at once with a one-line notice.
1530
1625
  input.onModeCycle?.(() => {
1531
- const next = MODES[(MODES.indexOf(getMode()) + 1) % MODES.length];
1626
+ const next = OFFERED_MODES[(OFFERED_MODES.indexOf(getMode()) + 1) % OFFERED_MODES.length];
1532
1627
  setMode(next);
1533
1628
  paintIdle();
1534
1629
  body.notice(`mode → ${next} (shift+tab cycles)`);
@@ -1539,6 +1634,29 @@ export async function chat(session, faux, input, autoCompact, nav) {
1539
1634
  // round 8: the startup resume is bound to currentRun — Ctrl+C during it
1540
1635
  // aborts the recovery, exactly like the interactive turns.
1541
1636
  await resolveUncertains(session, input, () => cancelled);
1637
+ // 0.40.0 item 9: a big session whose cache has gone cold compacts BEFORE
1638
+ // its first request, on the person's word — or without asking in
1639
+ // dontAsk, where compacting is not an approval. A piped session is left
1640
+ // to the auto policy, which fires above the hard tier on its own. Lines
1641
+ // typed meanwhile queue behind the compaction.
1642
+ //
1643
+ // The owner's dogfood: asked BEFORE the recovery run. A session whose
1644
+ // last run was cut resumes that run at once, and the resume's first
1645
+ // request is the whole cold prefix — asked afterwards, the question is
1646
+ // about a bill already paid (and the in-run tiers have usually shrunk the
1647
+ // context by then, so it is never asked at all). The compaction settles
1648
+ // before the run resumes: the run continues on the compacted projection.
1649
+ const cold = !cancelled && process.stdin.isTTY ? coldResumeOffer(session) : null;
1650
+ if (cold !== null) {
1651
+ if (getMode() === "dontAsk") {
1652
+ body.notice(`[dontAsk] ${coldResumeLine(cold.tokens, cold.minutes)} — compacting first`);
1653
+ dispatch("/compact", dispatchCtx);
1654
+ }
1655
+ else if ((await askPanel(input, coldResumeView(cold.tokens, cold.minutes))).action === "allow") {
1656
+ dispatch("/compact", dispatchCtx);
1657
+ }
1658
+ await chainRef.current;
1659
+ }
1542
1660
  if (!cancelled) {
1543
1661
  const recoveryRun = session.resume();
1544
1662
  currentRun = recoveryRun;
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
package/dist/config.js 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)
@@ -27,13 +27,14 @@
27
27
  * with an invalid value is likewise loud; unknown top-level keys are
28
28
  * ignored (forward compatibility).
29
29
  */
30
- import { readFileSync } from "node:fs";
30
+ import { readFileSync, statSync } from "node:fs";
31
+ import { homedir } from "node:os";
31
32
  import { join } from "node:path";
32
33
  import { kisoHome } from "./state.js";
33
34
  import { AuthError, getCredential, providerIdOf } from "./auth/credentials.js";
34
35
  import { BUILTIN_MANIFESTS } from "@vincemakes/kiso-runtime/internal";
35
36
  const KINDS = ["openai-compat", "anthropic", "openai-responses"];
36
- const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass"];
37
+ const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass", "dontAsk"];
37
38
  export class ConfigError extends Error {
38
39
  }
39
40
  /** Parse + validate one config file. Broken JSON / invalid known values →
@@ -66,6 +67,36 @@ export function parseConfig(text, source) {
66
67
  fail("theme", "belongs in the USER config — a terminal is a property of the person at it, not of the project");
67
68
  out.theme = obj.theme;
68
69
  }
70
+ if (obj.floor !== undefined) {
71
+ if (obj.floor !== "catastrophe" && obj.floor !== "off")
72
+ fail("floor", 'expected "catastrophe" or "off"');
73
+ if (source.startsWith("<cwd>"))
74
+ fail("floor", "belongs in the USER config — a project must never be able to lower the floor");
75
+ out.floor = obj.floor;
76
+ }
77
+ if (obj.protectedPaths !== undefined) {
78
+ if (source.startsWith("<cwd>"))
79
+ fail("protectedPaths", "belongs in the USER config — a project must never be able to change what kiso guards");
80
+ if (!Array.isArray(obj.protectedPaths))
81
+ fail("protectedPaths", "expected an array of paths");
82
+ for (const [i, p] of obj.protectedPaths.entries()) {
83
+ // a relative path would be read against whatever directory kiso
84
+ // runs in — a different file in every project
85
+ if (typeof p !== "string" || !(p.startsWith("/") || p.startsWith("~/")))
86
+ fail(`protectedPaths[${i}]`, "expected an absolute path or one starting with ~/");
87
+ // kiso protects FILES. A directory listed here would protect
88
+ // nothing under it — a security setting that silently does
89
+ // nothing — so it is refused where the person can see it.
90
+ // (Read at every agent build: a directory created later is
91
+ // caught at the next start or /reload.)
92
+ const path = p;
93
+ const full = path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
94
+ if (path.endsWith("/") || statSync(full, { throwIfNoEntry: false })?.isDirectory() === true) {
95
+ fail(`protectedPaths[${i}]`, `${path} is a directory — kiso protects files, so list the files in it (for ~/.aws: "~/.aws/credentials", "~/.aws/config")`);
96
+ }
97
+ }
98
+ out.protectedPaths = obj.protectedPaths;
99
+ }
69
100
  if (obj.checks !== undefined) {
70
101
  if (obj.checks === null || typeof obj.checks !== "object" || Array.isArray(obj.checks))
71
102
  fail("checks", "expected an object of name → command");
@@ -3,8 +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 type { SessionRoute } from "./projects.js";
6
7
  import type { AgentSession } from "@vincemakes/kiso-runtime";
7
8
  import { type LineInput } from "./state.js";
9
+ import type { UserInputVia } from "@vincemakes/kiso-core";
8
10
  /** Everything dispatch touches that chat() owns. */
9
11
  export interface DispatchCtx {
10
12
  readonly session: AgentSession;
@@ -23,8 +25,10 @@ export interface DispatchCtx {
23
25
  * gone; a binding that has not run yet paints none — an unmeasured
24
26
  * cache is not a 0% cache, and it is not the previous model's either. */
25
27
  readonly modelSwitched: () => void;
26
- /** submit a real turn: queue + chain (the turn closure lives in chat). */
27
- readonly submitTurn: (line: string) => void;
28
+ /** submit a real turn: queue + chain (the turn closure lives in chat).
29
+ * 0.40.0: `via` marks a skill turn — `line` is then the composed
30
+ * content, and the chip shows `via.line`. */
31
+ readonly submitTurn: (line: string, via?: UserInputVia) => void;
28
32
  /** the /status context estimate. */
29
33
  readonly estimateCtx: () => number;
30
34
  /** TUI2-R1 (E): the model's context window, as the session is
@@ -38,6 +42,10 @@ export interface DispatchCtx {
38
42
  readonly requestReload: () => void;
39
43
  /** every durable session id (the /resume validation + listing). */
40
44
  readonly sessions: () => readonly string[];
45
+ /** 0.40.0: an id outside this project's folder — refused with where to
46
+ * go, or opened where it is with a line (absent: every such id is "no
47
+ * such"). */
48
+ readonly route?: (id: string) => SessionRoute | null;
41
49
  /** the dock's session picker, when one exists (bare /resume). */
42
50
  readonly pickSession?: () => Promise<string | null>;
43
51
  }