@vincemakes/kiso-code 0.31.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/breaker.d.ts +57 -0
- package/dist/breaker.js +119 -0
- package/dist/chat.d.ts +21 -0
- package/dist/chat.js +74 -3
- package/dist/config.d.ts +3 -0
- package/dist/config.js +3 -0
- package/dist/dispatch.d.ts +24 -4
- package/dist/dispatch.js +184 -2
- package/dist/external-editor.d.ts +30 -0
- package/dist/external-editor.js +55 -0
- package/dist/index.js +190 -7
- package/dist/state.d.ts +35 -0
- package/dist/state.js +27 -0
- package/dist/trust-ui.js +111 -18
- package/package.json +16 -16
|
@@ -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;
|
package/dist/breaker.js
ADDED
|
@@ -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
|
-
|
|
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;
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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;
|
package/dist/dispatch.js
CHANGED
|
@@ -8,8 +8,10 @@ import { newSessionId } from "./session-id.js";
|
|
|
8
8
|
import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
|
|
9
9
|
import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
|
|
10
10
|
import { clipboardWrite, lastAnswer } from "./clipboard.js";
|
|
11
|
-
import { agentModel, body, bodyLog, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
|
|
11
|
+
import { agentModel, body, bodyLog, codingToolOptions, kisoHome, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setModelChoice, setLastBinding } from "./state.js";
|
|
12
12
|
import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
|
|
13
|
+
import { shellTool } from "@vincemakes/kiso-tools-node";
|
|
14
|
+
import { join } from "node:path";
|
|
13
15
|
/** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
|
|
14
16
|
* and its default, from the registry, shown wherever the profile is listed
|
|
15
17
|
* — kiso never silently drops a level, and now it shows the ones it takes.
|
|
@@ -77,8 +79,98 @@ import { oauthTokenThunk } from "./auth/token.js";
|
|
|
77
79
|
* replay routes through it too — a queued "/last" must never become a
|
|
78
80
|
* user turn (v2c: the rl lives in main, so lines arrive earlier and the
|
|
79
81
|
* queue is the common path). */
|
|
82
|
+
/** §2.2 — the `!` command in flight, if one is. A `!` command and a model
|
|
83
|
+
* run are never both running (the dispatcher is serialized on the chain),
|
|
84
|
+
* so one slot is the whole state. */
|
|
85
|
+
let activeBang = null;
|
|
86
|
+
/** esc while a `!` command runs kills it — the SAME abort the shell tool
|
|
87
|
+
* gives a model's call, so the whole process group goes, children
|
|
88
|
+
* included. Exported because the esc key reaches the editor first: chat
|
|
89
|
+
* offers it here before it reaches a model run, and only one of the two
|
|
90
|
+
* can be in flight. */
|
|
91
|
+
export function abortBangCommand() {
|
|
92
|
+
if (activeBang === null)
|
|
93
|
+
return false;
|
|
94
|
+
activeBang.abort();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/** §2.2 — what a submitted line asks for, if it asks for a shell at all.
|
|
98
|
+
* Split out from the dispatcher so the rule is testable without a
|
|
99
|
+
* terminal: `null` means this is not the gesture and the line is a turn.
|
|
100
|
+
*
|
|
101
|
+
* `!!` is checked before `!` because the longer prefix is the narrower
|
|
102
|
+
* claim; testing `!` first would swallow both. */
|
|
103
|
+
export function parseBang(trimmed) {
|
|
104
|
+
// a multi-line paste that merely begins with `!` is prose, the same
|
|
105
|
+
// rule `/` already follows one branch below.
|
|
106
|
+
if (trimmed.includes("\n") || !trimmed.startsWith("!"))
|
|
107
|
+
return null;
|
|
108
|
+
const send = !trimmed.startsWith("!!");
|
|
109
|
+
return { command: trimmed.slice(send ? 1 : 2).trim(), send };
|
|
110
|
+
}
|
|
111
|
+
/** §2.2 — `!cmd` runs a command and SENDS it, `!!cmd` runs one and shows
|
|
112
|
+
* it. Both go through the shell tool's own runner, so the timeout (30 s),
|
|
113
|
+
* the output cap (100,000 chars, which names what it dropped), the
|
|
114
|
+
* whole-tree kill and the STRIPPED environment are the tool's and not a
|
|
115
|
+
* second set of rules. The `!`/`!!` difference is what the MODEL sees,
|
|
116
|
+
* never what leaves the machine.
|
|
117
|
+
*
|
|
118
|
+
* No approval panel, at any tier including plan: the human typed the
|
|
119
|
+
* command. That is not a policy exemption, it is structural — this
|
|
120
|
+
* dispatcher only ever sees lines a human submitted, so a model cannot
|
|
121
|
+
* reach this branch. */
|
|
122
|
+
function runBang(command, send, ctx) {
|
|
123
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
activeBang = controller;
|
|
126
|
+
try {
|
|
127
|
+
const tool = shellTool(codingToolOptions());
|
|
128
|
+
const result = await tool.execute({ command }, { signal: controller.signal });
|
|
129
|
+
// one fenced block, read as a terminal transcript: the command
|
|
130
|
+
// and what it printed. The tool's own overflow note rides inside
|
|
131
|
+
// `content`, so a capped run says so in the same words a model's
|
|
132
|
+
// call would.
|
|
133
|
+
const block = ["```console", `$ ${command}`, result.content, "```"].join("\n");
|
|
134
|
+
if (send) {
|
|
135
|
+
// an ORDINARY user turn: durable, in the log, and the model
|
|
136
|
+
// sees exactly what the human saw.
|
|
137
|
+
ctx.submitTurn(block);
|
|
138
|
+
return; // submitTurn owns the prompt from here
|
|
139
|
+
}
|
|
140
|
+
bodyLog(block, "words");
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
activeBang = null;
|
|
144
|
+
}
|
|
145
|
+
ctx.input.prompt();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
80
148
|
export function dispatch(line, ctx) {
|
|
81
149
|
const trimmed = line.trim();
|
|
150
|
+
// §2.2 — the `!` gesture is the COMPOSER's alone. A piped session, `-p`
|
|
151
|
+
// and `--task-file` keep `!` as ordinary text: those lines are content,
|
|
152
|
+
// and a headless run that shelled out because a prompt began with `!`
|
|
153
|
+
// would be executing its input.
|
|
154
|
+
if (dock.active && !trimmed.includes("\n")) {
|
|
155
|
+
if (trimmed.startsWith("\\!")) {
|
|
156
|
+
// the ONE escape: `\!` sends the literal text starting with `!`.
|
|
157
|
+
ctx.submitTurn(line.replace("\\!", "!"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const bang = parseBang(trimmed);
|
|
161
|
+
if (bang !== null) {
|
|
162
|
+
const { command, send } = bang;
|
|
163
|
+
if (command === "") {
|
|
164
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
165
|
+
bodyLog("!<command> runs it and sends the result · !!<command> runs it and shows it here · \\! sends a literal !");
|
|
166
|
+
ctx.input.prompt();
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
runBang(command, send, ctx);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
82
174
|
if (trimmed === "/help") {
|
|
83
175
|
// KC3.5 slice ⓪ (the extraction): the ROWS moved to the terminal
|
|
84
176
|
// layer's strings module (helpRows — the KC3 §1 pattern: what the
|
|
@@ -170,6 +262,75 @@ export function dispatch(line, ctx) {
|
|
|
170
262
|
ctx.input.prompt();
|
|
171
263
|
return;
|
|
172
264
|
}
|
|
265
|
+
if (trimmed === "\x07editor") {
|
|
266
|
+
// §2.4 — ctrl+g hands the composer to the human's own editor.
|
|
267
|
+
//
|
|
268
|
+
// The split: the EDITOR owns the terminal handover (raw mode, the
|
|
269
|
+
// mouse, bracketed paste — see `externalEdit`), and this owns the
|
|
270
|
+
// spawn, because the tui package is pure terminal and spawning is
|
|
271
|
+
// not. Neither reaches into the other.
|
|
272
|
+
//
|
|
273
|
+
// $VISUAL before $EDITOR — VISUAL is the variable for a full-screen
|
|
274
|
+
// editor and EDITOR is the fallback — and NO guess beyond them. In
|
|
275
|
+
// an environment that sets neither, dropping someone into an editor
|
|
276
|
+
// they did not choose is the product deciding for them.
|
|
277
|
+
//
|
|
278
|
+
// It never SUBMITS. What comes back is text in the composer and
|
|
279
|
+
// enter is still the human's: one key that both opens an editor and
|
|
280
|
+
// sends is a gesture with no way back.
|
|
281
|
+
const bin = process.env.VISUAL ?? process.env.EDITOR ?? "";
|
|
282
|
+
if (bin.trim() === "") {
|
|
283
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
284
|
+
bodyLog("no editor configured — set $VISUAL or $EDITOR to open one with ctrl+g");
|
|
285
|
+
ctx.input.prompt();
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// The process spawn lives in its own module, reached HERE and not
|
|
290
|
+
// imported at the top. The PTY manifest gate classifies a test by
|
|
291
|
+
// the RESOURCE closure of what it imports, so a static process
|
|
292
|
+
// import in the dispatcher moves every test that imports the
|
|
293
|
+
// dispatcher into the single-file-serial pool — it took `!cmd`'s
|
|
294
|
+
// millisecond-long parser test with it. Loading the module at the
|
|
295
|
+
// key makes the closure honest: importing the dispatcher cannot
|
|
296
|
+
// start a process.
|
|
297
|
+
//
|
|
298
|
+
// (The gate scans SOURCE, comments included, so naming the node
|
|
299
|
+
// module here in prose would reclassify this file just as surely as
|
|
300
|
+
// importing it. Deliberately over-wide — see the gate's own note.)
|
|
301
|
+
//
|
|
302
|
+
// Joining the chain is a FIX, not the price of that. Handing the
|
|
303
|
+
// terminal to a full-screen editor while a turn is streaming rows
|
|
304
|
+
// into that same terminal gives the child a screen kiso is still
|
|
305
|
+
// painting over. An idle chain resolves on the next microtask, so
|
|
306
|
+
// the press that is not mid-turn is unchanged.
|
|
307
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
308
|
+
const { runExternalEditor } = await import("./external-editor.js");
|
|
309
|
+
const root = join(kisoHome(), "tmp");
|
|
310
|
+
ctx.input.externalEdit?.((text) => runExternalEditor(bin, root, text, (msg) => body.notice(msg)));
|
|
311
|
+
// the external program drew over the screen; the model is the only
|
|
312
|
+
// authority on what should be there (R14).
|
|
313
|
+
body.reprint();
|
|
314
|
+
ctx.input.prompt();
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (trimmed === "\x14think") {
|
|
319
|
+
// §2.3 — ctrl+t folds the committed thinking blocks, and folds them
|
|
320
|
+
// back. DC-50's mechanism, not a second one: one boolean, then the
|
|
321
|
+
// session is reprinted, so the blocks already on screen obey the
|
|
322
|
+
// switch rather than only the next ones.
|
|
323
|
+
//
|
|
324
|
+
// The folded row is `foldThinking`'s, which is what the PIPE writes
|
|
325
|
+
// — so thinking has two renderings in the product and not three,
|
|
326
|
+
// and the pipe's byte-identity gate is this row's gate too.
|
|
327
|
+
//
|
|
328
|
+
// The live `thinking…` placeholder is NOT touched: it belongs to
|
|
329
|
+
// the live region, and this switch is about committed blocks.
|
|
330
|
+
body.toggleThinking();
|
|
331
|
+
ctx.input.prompt();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
173
334
|
if (trimmed === "\x18copy" || trimmed === "/copy") {
|
|
174
335
|
// E1 §3 — the last answer onto the clipboard, as RAW MARKDOWN.
|
|
175
336
|
//
|
|
@@ -493,12 +654,18 @@ export function dispatch(line, ctx) {
|
|
|
493
654
|
setLastBinding(binding);
|
|
494
655
|
setAgentModel(profile.model, profile.baseUrl);
|
|
495
656
|
setCurrentModelName(arg);
|
|
657
|
+
// §2.5: the ONE source a reload reads for the model — a
|
|
658
|
+
// switch made here must survive the rebuild.
|
|
659
|
+
setModelChoice(arg);
|
|
496
660
|
// OR-7 (owner, 2026-09-09): the status row is repainted BY the
|
|
497
661
|
// switch — it used to keep the old model until the next
|
|
498
662
|
// recap repainted it, so "takes effect on the next turn"
|
|
499
663
|
// read as "did not take". Painted BEFORE the notice so the
|
|
500
664
|
// frame that carries the notice already carries the row.
|
|
501
|
-
|
|
665
|
+
// DF-0311-F1: repainted with NO meter — the 0.31.1 dogfood
|
|
666
|
+
// (and the owner's) read `gpt-6-astra · CH 92%`, the new
|
|
667
|
+
// model beside the previous model's figure.
|
|
668
|
+
ctx.modelSwitched();
|
|
502
669
|
body.notice(`model → ${profName} (${profile.model}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
|
|
503
670
|
}
|
|
504
671
|
}
|
|
@@ -634,6 +801,21 @@ export function dispatch(line, ctx) {
|
|
|
634
801
|
ctx.requestSwitch(newSessionId(sessionsDir()));
|
|
635
802
|
return;
|
|
636
803
|
}
|
|
804
|
+
if (trimmed === "/reload") {
|
|
805
|
+
// §2.5: extensions, skills and config, read again into a session that
|
|
806
|
+
// keeps its conversation. It is a REBUILD — the tool registry is
|
|
807
|
+
// built in the agent's constructor and has no unregister, so nothing
|
|
808
|
+
// short of constructing the agent again can drop a tool or add one.
|
|
809
|
+
// The durable log is untouched: a reload is invisible in the record,
|
|
810
|
+
// because nothing the model said or did changes.
|
|
811
|
+
if (ctx.isRunning()) {
|
|
812
|
+
body.notice("[/reload] a run is in flight — let it finish (esc stops it), then reload");
|
|
813
|
+
ctx.input.prompt();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
ctx.requestReload();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
637
819
|
if (trimmed === "/resume" || trimmed.startsWith("/resume ")) {
|
|
638
820
|
// the mini-spec: the in-session door to the durable sessions —
|
|
639
821
|
// /resume <id> switches directly; bare /resume opens the SAME
|