@vincemakes/kiso-code 0.31.1 → 0.32.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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * LT-2 — the loop breaker (kiso-doc/kiso-lt1-mini-spec-2026-09-09.md, part
3
+ * one, corrected by the chain law — see below).
4
+ *
5
+ * An agent that keeps issuing the SAME failing call — same tool, same
6
+ * input, failure after failure — is not making progress, and since R3e
7
+ * removed the silent turn limit nothing stopped it. This extension does,
8
+ * in the one place the product already decides tool calls: the approval
9
+ * chain. It rides the chain like the five mode tiers do (a `decide()` that
10
+ * speaks or abstains), placed at the HEAD so that when it speaks, the
11
+ * verdict's `decidedBy` names it.
12
+ *
13
+ * THE VERDICT IS A DENY WITH A REASON, NOT AN ASK. The spec asked for a
14
+ * human question; the chain composes deny > allow > ask over the speaking
15
+ * verdicts, so an `ask` from here would lose to the tier's `allow` (a read
16
+ * under `default`, anything under `bypass`) and the loop would go on. A
17
+ * deny wins over every tier — the same law that lets a user extension's
18
+ * deny beat `bypass` — and the model receives it as the call's result with
19
+ * the reason (the 1c grammar: refuse with a reason and continue), so the
20
+ * next turn has to change course. The human sees the denial on the
21
+ * transcript with `decidedBy: breaker`; the 50-turn checkpoint (part two)
22
+ * is where a human is asked.
23
+ *
24
+ * Counting: CONSECUTIVE failed results of one (tool, input) key within a
25
+ * run. A success, a different call, or the run's terminal resets it. The
26
+ * third identical attempt is denied. N = 3 is named below; dogfood moves it
27
+ * with a finding, never silently.
28
+ */
29
+ import type { Event, KisoExtension, PolicyVerdict } from "@vincemakes/kiso-core";
30
+ /** The third identical failed attempt is the one refused. */
31
+ export declare const BREAKER_LIMIT = 3;
32
+ /** The name the verdict wears (`decidedBy`). */
33
+ export declare const BREAKER_NAME = "breaker";
34
+ /** A stable key for (tool, input): object keys sorted at every depth, so
35
+ * two calls that mean the same thing key the same whatever order the
36
+ * model wrote the fields in. */
37
+ export declare function callKey(name: string, input: unknown): string;
38
+ /** The per-run state, kept small: the last failed key and how many times
39
+ * in a row it failed. `calls` maps a callId to its key while a call is in
40
+ * flight (the failure arrives on the result event, keyed by callId). */
41
+ export interface BreakerState {
42
+ lastKey: string | null;
43
+ failures: number;
44
+ readonly calls: Map<string, string>;
45
+ }
46
+ export declare function breakerState(): BreakerState;
47
+ /** What the log says, applied to the state. Exported so the rule is testable
48
+ * as a pure function of the event sequence. */
49
+ export declare function observe(state: BreakerState, event: Event): void;
50
+ /** The verdict for a call about to be decided: the (limit − 1)th consecutive
51
+ * failure of this exact key has already happened, so this attempt would be
52
+ * the limit-th — refused, with the count in the reason. */
53
+ export declare function verdict(state: BreakerState, name: string, input: unknown, limit?: number): PolicyVerdict;
54
+ /** The extension: one state per process (a session runs one run at a time,
55
+ * and the terminal resets it), the log observed through the hook, the
56
+ * verdict through the chain. */
57
+ export declare function breakerExtension(limit?: number): KisoExtension;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * LT-2 — the loop breaker (kiso-doc/kiso-lt1-mini-spec-2026-09-09.md, part
3
+ * one, corrected by the chain law — see below).
4
+ *
5
+ * An agent that keeps issuing the SAME failing call — same tool, same
6
+ * input, failure after failure — is not making progress, and since R3e
7
+ * removed the silent turn limit nothing stopped it. This extension does,
8
+ * in the one place the product already decides tool calls: the approval
9
+ * chain. It rides the chain like the five mode tiers do (a `decide()` that
10
+ * speaks or abstains), placed at the HEAD so that when it speaks, the
11
+ * verdict's `decidedBy` names it.
12
+ *
13
+ * THE VERDICT IS A DENY WITH A REASON, NOT AN ASK. The spec asked for a
14
+ * human question; the chain composes deny > allow > ask over the speaking
15
+ * verdicts, so an `ask` from here would lose to the tier's `allow` (a read
16
+ * under `default`, anything under `bypass`) and the loop would go on. A
17
+ * deny wins over every tier — the same law that lets a user extension's
18
+ * deny beat `bypass` — and the model receives it as the call's result with
19
+ * the reason (the 1c grammar: refuse with a reason and continue), so the
20
+ * next turn has to change course. The human sees the denial on the
21
+ * transcript with `decidedBy: breaker`; the 50-turn checkpoint (part two)
22
+ * is where a human is asked.
23
+ *
24
+ * Counting: CONSECUTIVE failed results of one (tool, input) key within a
25
+ * run. A success, a different call, or the run's terminal resets it. The
26
+ * third identical attempt is denied. N = 3 is named below; dogfood moves it
27
+ * with a finding, never silently.
28
+ */
29
+ /** The third identical failed attempt is the one refused. */
30
+ export const BREAKER_LIMIT = 3;
31
+ /** The name the verdict wears (`decidedBy`). */
32
+ export const BREAKER_NAME = "breaker";
33
+ /** A stable key for (tool, input): object keys sorted at every depth, so
34
+ * two calls that mean the same thing key the same whatever order the
35
+ * model wrote the fields in. */
36
+ export function callKey(name, input) {
37
+ return `${name}\0${stable(input)}`;
38
+ }
39
+ function stable(v) {
40
+ if (Array.isArray(v))
41
+ return `[${v.map(stable).join(",")}]`;
42
+ if (v !== null && typeof v === "object") {
43
+ const o = v;
44
+ return `{${Object.keys(o)
45
+ .sort()
46
+ .map((k) => `${JSON.stringify(k)}:${stable(o[k])}`)
47
+ .join(",")}}`;
48
+ }
49
+ return JSON.stringify(v) ?? "undefined";
50
+ }
51
+ export function breakerState() {
52
+ return { lastKey: null, failures: 0, calls: new Map() };
53
+ }
54
+ /** What the log says, applied to the state. Exported so the rule is testable
55
+ * as a pure function of the event sequence. */
56
+ export function observe(state, event) {
57
+ switch (event.type) {
58
+ case "tool_call_end":
59
+ state.calls.set(event.callId, callKey(event.name, event.input));
60
+ return;
61
+ case "tool_result": {
62
+ const key = state.calls.get(event.callId);
63
+ state.calls.delete(event.callId);
64
+ if (key === undefined)
65
+ return;
66
+ if (!event.isError) {
67
+ state.lastKey = null;
68
+ state.failures = 0;
69
+ return;
70
+ }
71
+ if (state.lastKey === key)
72
+ state.failures += 1;
73
+ else {
74
+ state.lastKey = key;
75
+ state.failures = 1;
76
+ }
77
+ return;
78
+ }
79
+ case "terminal":
80
+ // a run's end is a clean slate: the next run starts at zero
81
+ state.lastKey = null;
82
+ state.failures = 0;
83
+ state.calls.clear();
84
+ return;
85
+ default:
86
+ return;
87
+ }
88
+ }
89
+ /** The verdict for a call about to be decided: the (limit − 1)th consecutive
90
+ * failure of this exact key has already happened, so this attempt would be
91
+ * the limit-th — refused, with the count in the reason. */
92
+ export function verdict(state, name, input, limit = BREAKER_LIMIT) {
93
+ if (state.lastKey !== null && state.lastKey === callKey(name, input) && state.failures >= limit - 1) {
94
+ return {
95
+ action: "deny",
96
+ reason: `${BREAKER_NAME}: the same ${name} call failed ${state.failures} times in a row — change the input or the approach; the identical call is not tried again`,
97
+ };
98
+ }
99
+ return { action: "abstain" };
100
+ }
101
+ /** The extension: one state per process (a session runs one run at a time,
102
+ * and the terminal resets it), the log observed through the hook, the
103
+ * verdict through the chain. */
104
+ export function breakerExtension(limit = BREAKER_LIMIT) {
105
+ const state = breakerState();
106
+ return {
107
+ name: BREAKER_NAME,
108
+ hooks: {
109
+ async onEvent(event) {
110
+ observe(state, event);
111
+ },
112
+ },
113
+ approvals: [
114
+ {
115
+ decide: async (payload) => verdict(state, payload.name, payload.input, limit),
116
+ },
117
+ ],
118
+ };
119
+ }
package/dist/chat.d.ts CHANGED
@@ -170,6 +170,19 @@ export declare function parseSaferOptions(text: string): SaferOption[] | null;
170
170
  * echo is UI, the chip is the record; the momentary double-render is
171
171
  * the design's explicit point).
172
172
  */
173
+ /** LT-2b — the turn checkpoint (kiso-doc/kiso-lt1-mini-spec-2026-09-09.md,
174
+ * part two). R3e stands: no hard turn limit on an interactive session. What
175
+ * a long run gets instead is a QUESTION every CHECKPOINT_TURNS model turns —
176
+ * the run is paused between two model turns (the generator is simply not
177
+ * pulled while the panel is up; the kernel does nothing meanwhile) and the
178
+ * human answers. "Keep going" resumes it, the next question one checkpoint
179
+ * later; "Stop here" aborts the run — `aborted by user`, resumable, never
180
+ * silent. A decline (esc) is "keep going": the run was already running, and
181
+ * esc on a panel is the panel's decline, not the run's abort. Headless
182
+ * entries never see it: the panel needs the dock, and a pipe, `-p` or a
183
+ * task file has none. 50 is named here; a finding moves it, never a stray
184
+ * edit. */
185
+ export declare const CHECKPOINT_TURNS = 50;
173
186
  export declare function consumeRun(session: AgentSession, run: Run, input: LineInput, turnNo: number, faux: boolean, statusCb: ((usage: RunUsage, ctxRatio: number, costUsd?: number | null) => void) | null,
174
187
  /** W21: the amend words ("Yes + feedback") ride the NEXT user turn —
175
188
  * threaded from chat's submitTurn; absent in the recovery flow
@@ -185,6 +198,14 @@ export type ChatEnd = {
185
198
  } | {
186
199
  readonly next: "switch";
187
200
  readonly id: string;
201
+ }
202
+ /** §2.5: the caller REBUILDS the agent and re-enters on the same id.
203
+ * Not "switch": switching keeps the agent and only changes the id,
204
+ * which would reload nothing at all — the tool registry lives in the
205
+ * agent's constructor. */
206
+ | {
207
+ readonly next: "reload";
208
+ readonly id: string;
188
209
  };
189
210
  /** The session-navigation seam main provides: the OTHER sessions'
190
211
  * ids, and (when a dock is up) the existing picker. */
package/dist/chat.js CHANGED
@@ -6,11 +6,11 @@
6
6
  */
7
7
  import { readFileSync, statSync } from "node:fs";
8
8
  import { escapeTerminal, cacheHitPct, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, verifyOfferView, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
9
- import { deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
9
+ import { askView, deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
11
  import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
12
12
  import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
13
- import { dispatch } from "./dispatch.js";
13
+ import { dispatch, abortBangCommand } from "./dispatch.js";
14
14
  import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
15
15
  import { attachImages } from "./attachments.js";
16
16
  import { lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
@@ -542,6 +542,37 @@ function toRenderInput(ev) {
542
542
  * echo is UI, the chip is the record; the momentary double-render is
543
543
  * the design's explicit point).
544
544
  */
545
+ /** LT-2b — the turn checkpoint (kiso-doc/kiso-lt1-mini-spec-2026-09-09.md,
546
+ * part two). R3e stands: no hard turn limit on an interactive session. What
547
+ * a long run gets instead is a QUESTION every CHECKPOINT_TURNS model turns —
548
+ * the run is paused between two model turns (the generator is simply not
549
+ * pulled while the panel is up; the kernel does nothing meanwhile) and the
550
+ * human answers. "Keep going" resumes it, the next question one checkpoint
551
+ * later; "Stop here" aborts the run — `aborted by user`, resumable, never
552
+ * silent. A decline (esc) is "keep going": the run was already running, and
553
+ * esc on a panel is the panel's decline, not the run's abort. Headless
554
+ * entries never see it: the panel needs the dock, and a pipe, `-p` or a
555
+ * task file has none. 50 is named here; a finding moves it, never a stray
556
+ * edit. */
557
+ export const CHECKPOINT_TURNS = 50;
558
+ async function checkpointAsk(input, turns) {
559
+ const verdict = await askPanel(input, askView({
560
+ questions: [
561
+ {
562
+ question: `${turns} turns and still working — keep going?`,
563
+ header: "checkpoint",
564
+ options: [
565
+ { label: "Keep going", description: `the next question at ${turns + CHECKPOINT_TURNS}` },
566
+ { label: "Stop here", description: "the run ends as aborted by you; kiso resume continues it later" },
567
+ ],
568
+ },
569
+ ],
570
+ }));
571
+ if (verdict.action !== "answers" || !("answers" in verdict.result))
572
+ return true; // declined: nothing changes
573
+ const first = verdict.result.answers[0];
574
+ return !(first !== undefined && "choice" in first && first.choice === "Stop here");
575
+ }
545
576
  export async function consumeRun(session, run, input, turnNo, faux, statusCb,
546
577
  /** W21: the amend words ("Yes + feedback") ride the NEXT user turn —
547
578
  * threaded from chat's submitTurn; absent in the recovery flow
@@ -572,8 +603,16 @@ submitTurn) {
572
603
  tailers.delete(callId);
573
604
  };
574
605
  try {
606
+ let modelTurns = 0;
575
607
  for await (const ev of run) {
576
608
  last = ev;
609
+ // LT-2b: a model turn ends with its stop; every CHECKPOINT_TURNS of them
610
+ // the human is asked before the run is pulled any further
611
+ if (ev.type === "stop" && dock.active) {
612
+ modelTurns += 1;
613
+ if (modelTurns % CHECKPOINT_TURNS === 0 && !(await checkpointAsk(input, modelTurns)))
614
+ run.abort();
615
+ }
577
616
  if (ev.type !== "thinking") {
578
617
  if (thinkingSince !== null) {
579
618
  thoughtSeconds += (Date.now() - thinkingSince) / 1000;
@@ -878,6 +917,9 @@ export async function chat(session, faux, input, autoCompact, nav) {
878
917
  // the switch directive — set once by dispatch's /clear or /resume,
879
918
  // resolved through the end signal so the final awaits still run
880
919
  let switchTo = null;
920
+ // §2.5: the reload directive — same resolution path as switchTo, so the
921
+ // final awaits still run before chat() returns.
922
+ let reloadReq = false;
881
923
  let resolveEnd = () => { };
882
924
  const endSignal = new Promise((r) => {
883
925
  resolveEnd = r;
@@ -1031,6 +1073,13 @@ export async function chat(session, faux, input, autoCompact, nav) {
1031
1073
  exitAtEmptyPrompt();
1032
1074
  });
1033
1075
  input.onEscape(() => {
1076
+ // §2.2: a `!` command is offered the key FIRST. The two are never
1077
+ // both in flight — the dispatcher is serialized on the chain — so
1078
+ // this is an ordering, not a precedence rule.
1079
+ if (abortBangCommand()) {
1080
+ console.log("\n[aborting command]");
1081
+ return;
1082
+ }
1034
1083
  if (currentRun) {
1035
1084
  console.log("\n[aborting run]");
1036
1085
  pendingAsk?.();
@@ -1137,6 +1186,17 @@ export async function chat(session, faux, input, autoCompact, nav) {
1137
1186
  // a turn ended. One call, and the SAME formatter — a boot-time copy of
1138
1187
  // the row would drift from the real one the moment either changed.
1139
1188
  paintIdle();
1189
+ // DF-0311-F1 (the 0.31.1 dogfood, reproduced by the owner): the meter is
1190
+ // the last TURN's figure. After /model the row named the NEW model beside
1191
+ // the OLD model's CH until the next recap — and beside a model whose
1192
+ // cache is unobservable (OR-10) that is a measurement it never had. A new
1193
+ // binding starts with no meter; its first turn paints the first figure.
1194
+ // The session's running cost is untouched: it is the session's, not the
1195
+ // binding's, and the row does not render it anyway.
1196
+ const modelSwitched = () => {
1197
+ runUsage = { in: null, out: null, cache: null, known: false };
1198
+ paintIdle();
1199
+ };
1140
1200
  const statusCb = (u, ctx, costUsd) => {
1141
1201
  runUsage = u;
1142
1202
  addCost(costUsd ?? null);
@@ -1207,6 +1267,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1207
1267
  chainRef,
1208
1268
  isRunning: () => currentRun !== null,
1209
1269
  paintIdle,
1270
+ modelSwitched,
1210
1271
  submitTurn,
1211
1272
  estimateCtx: () => displayCtxRatio(session),
1212
1273
  contextWindow: () => contextWindowTokens(),
@@ -1217,6 +1278,10 @@ export async function chat(session, faux, input, autoCompact, nav) {
1217
1278
  switchTo = id;
1218
1279
  resolveEnd();
1219
1280
  },
1281
+ requestReload: () => {
1282
+ reloadReq = true;
1283
+ resolveEnd();
1284
+ },
1220
1285
  sessions: () => nav?.sessions() ?? [],
1221
1286
  ...(nav?.pick !== undefined ? { pickSession: nav.pick } : {}),
1222
1287
  };
@@ -1256,6 +1321,8 @@ export async function chat(session, faux, input, autoCompact, nav) {
1256
1321
  // chain action (the sentinel's control char marks the key, so a typed
1257
1322
  // "expand" turn is never intercepted).
1258
1323
  input.onExpand(() => dispatch("\x12expand", dispatchCtx));
1324
+ input.onThink?.(() => dispatch("\x14think", dispatchCtx));
1325
+ input.onEditor?.(() => dispatch("\x07editor", dispatchCtx));
1259
1326
  // E1 §3 — ctrl+x and `/copy` are the same action reached two ways, so
1260
1327
  // they are the same sentinel: one implementation, one behaviour.
1261
1328
  input.onCopy(() => dispatch("\x18copy", dispatchCtx));
@@ -1323,5 +1390,9 @@ export async function chat(session, faux, input, autoCompact, nav) {
1323
1390
  // runs before the exit or the chain is already settled. One level is
1324
1391
  // enough: the /compact segment appends nothing of its own.
1325
1392
  await chainRef.current;
1326
- return switchTo === null ? { next: "exit" } : { next: "switch", id: switchTo };
1393
+ if (switchTo !== null)
1394
+ return { next: "switch", id: switchTo };
1395
+ // a switch beats a reload: /resume and /clear are going somewhere else,
1396
+ // and the agent they land on is rebuilt by the caller either way.
1397
+ return reloadReq ? { next: "reload", id: session.id } : { next: "exit" };
1327
1398
  }
package/dist/config.d.ts CHANGED
@@ -46,6 +46,9 @@ export interface ModelProfile {
46
46
  /** PH-1c.1: opt-in Anthropic prompt caching for this profile —
47
47
  * default off (a request-byte cost behavior never flips silently). */
48
48
  readonly promptCaching?: boolean;
49
+ /** LT-1: milliseconds the model stream may go silent before the request
50
+ * is aborted and retried. Default 120,000; 0 disables the watchdog. */
51
+ readonly streamIdleMs?: number;
49
52
  }
50
53
  export interface AutoCompactConfig {
51
54
  readonly thresholdRatio: number;
package/dist/config.js CHANGED
@@ -100,12 +100,15 @@ export function parseConfig(text, source) {
100
100
  fail(`models.${name}.baseUrl`, "expected a string");
101
101
  if (p.promptCaching !== undefined && typeof p.promptCaching !== "boolean")
102
102
  fail(`models.${name}.promptCaching`, "expected a boolean");
103
+ if (p.streamIdleMs !== undefined && (typeof p.streamIdleMs !== "number" || !Number.isFinite(p.streamIdleMs) || p.streamIdleMs < 0))
104
+ fail(`models.${name}.streamIdleMs`, "expected a non-negative number of milliseconds (0 disables the stream watchdog)");
103
105
  models[name] = {
104
106
  kind: p.kind,
105
107
  model: p.model,
106
108
  ...(typeof p.apiKeyEnv === "string" ? { apiKeyEnv: p.apiKeyEnv } : {}),
107
109
  ...(typeof p.baseUrl === "string" ? { baseUrl: p.baseUrl } : {}),
108
110
  ...(typeof p.promptCaching === "boolean" ? { promptCaching: p.promptCaching } : {}),
111
+ ...(typeof p.streamIdleMs === "number" ? { streamIdleMs: p.streamIdleMs } : {}),
109
112
  };
110
113
  }
111
114
  out.models = models;
@@ -18,6 +18,11 @@ export interface DispatchCtx {
18
18
  readonly isRunning: () => boolean;
19
19
  /** the /mode switch repaints the status bar at once. */
20
20
  readonly paintIdle: () => void;
21
+ /** DF-0311-F1: a successful /model switch repaints the row with NO meter.
22
+ * The meter is the last TURN's figure, measured on the binding that is
23
+ * gone; a binding that has not run yet paints none — an unmeasured
24
+ * cache is not a 0% cache, and it is not the previous model's either. */
25
+ readonly modelSwitched: () => void;
21
26
  /** submit a real turn: queue + chain (the turn closure lives in chat). */
22
27
  readonly submitTurn: (line: string) => void;
23
28
  /** the /status context estimate. */
@@ -28,13 +33,28 @@ export interface DispatchCtx {
28
33
  /** the /resume+/clear mini-spec: end this chat() with a switch to
29
34
  * another session — main re-enters chat there; the editor survives. */
30
35
  readonly requestSwitch: (id: string) => void;
36
+ /** §2.5: end this chat() with a REBUILD of the agent on the SAME
37
+ * session — extensions, skills and config are read again. */
38
+ readonly requestReload: () => void;
31
39
  /** every durable session id (the /resume validation + listing). */
32
40
  readonly sessions: () => readonly string[];
33
41
  /** the dock's session picker, when one exists (bare /resume). */
34
42
  readonly pickSession?: () => Promise<string | null>;
35
43
  }
36
- /** The ONE dispatcher — slash commands, exit, and turns. The recovery
37
- * replay routes through it too — a queued "/last" must never become a
38
- * user turn (v2c: the rl lives in main, so lines arrive earlier and the
39
- * queue is the common path). */
44
+ /** esc while a `!` command runs kills it — the SAME abort the shell tool
45
+ * gives a model's call, so the whole process group goes, children
46
+ * included. Exported because the esc key reaches the editor first: chat
47
+ * offers it here before it reaches a model run, and only one of the two
48
+ * can be in flight. */
49
+ export declare function abortBangCommand(): boolean;
50
+ /** §2.2 — what a submitted line asks for, if it asks for a shell at all.
51
+ * Split out from the dispatcher so the rule is testable without a
52
+ * terminal: `null` means this is not the gesture and the line is a turn.
53
+ *
54
+ * `!!` is checked before `!` because the longer prefix is the narrower
55
+ * claim; testing `!` first would swallow both. */
56
+ export declare function parseBang(trimmed: string): {
57
+ readonly command: string;
58
+ readonly send: boolean;
59
+ } | null;
40
60
  export declare function dispatch(line: string, ctx: DispatchCtx): void;