@vincemakes/kiso-code 0.31.0 → 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/auth/oauth/chatgpt.js +6 -0
- package/dist/auth/oauth/index.d.ts +5 -0
- package/dist/breaker.d.ts +57 -0
- package/dist/breaker.js +119 -0
- package/dist/chat.d.ts +30 -0
- package/dist/chat.js +112 -5
- package/dist/config.d.ts +3 -0
- package/dist/config.js +20 -0
- package/dist/dispatch.d.ts +24 -4
- package/dist/dispatch.js +257 -6
- package/dist/external-editor.d.ts +30 -0
- package/dist/external-editor.js +55 -0
- package/dist/index.js +303 -34
- package/dist/state.d.ts +35 -0
- package/dist/state.js +27 -0
- package/dist/trust-ui.js +111 -18
- package/dist/update-check.d.ts +9 -3
- package/dist/update-check.js +27 -28
- package/package.json +16 -16
- package/dist/diff.d.ts +0 -32
- package/dist/diff.js +0 -122
|
@@ -164,6 +164,12 @@ export const chatgptFlow = {
|
|
|
164
164
|
const flow = createAuthorizationFlow(redirectUri);
|
|
165
165
|
const server = await startCallbackServer(flow.state, cb.host, cb.port);
|
|
166
166
|
interaction.notify(`open this URL to sign in:\n ${flow.url}`);
|
|
167
|
+
// OR-4 (owner, 2026-09-09): the URL is opened for the person when the
|
|
168
|
+
// caller can — the printed line stays the fallback and the paste path.
|
|
169
|
+
if (interaction.open !== undefined) {
|
|
170
|
+
interaction.open(flow.url);
|
|
171
|
+
interaction.notify("(opened in your browser — if nothing opened, copy the URL above)");
|
|
172
|
+
}
|
|
167
173
|
let code = null;
|
|
168
174
|
if (server !== null) {
|
|
169
175
|
interaction.notify(`waiting for the browser to come back to ${redirectUri} … (or paste the redirected URL here)`);
|
|
@@ -14,6 +14,11 @@ export interface LoginInteraction {
|
|
|
14
14
|
/** Ask the person for a line (the pasted redirect URL or code when the
|
|
15
15
|
* local callback cannot be reached); resolves "" when unavailable. */
|
|
16
16
|
readonly prompt: (question: string) => Promise<string>;
|
|
17
|
+
/** OR-4: open the authorize URL in the person's browser, when there is
|
|
18
|
+
* one. OPTIONAL — the CLI passes it on a TTY; tests and pipes never do,
|
|
19
|
+
* so nothing opens a browser under a test rig. The URL is always
|
|
20
|
+
* printed too: this is a convenience, never the only path. */
|
|
21
|
+
readonly open?: (url: string) => void;
|
|
17
22
|
readonly signal?: AbortSignal;
|
|
18
23
|
/** Test seam: the callback listener's host/port (defaults 127.0.0.1:1455). */
|
|
19
24
|
readonly callback?: {
|
|
@@ -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
|
@@ -38,6 +38,15 @@ export declare function estimateCtxRatio(session: AgentSession): number;
|
|
|
38
38
|
* their continuation envelopes, the output reserve when known) over the
|
|
39
39
|
* window. Still ~ (chars/4). The auto-compact policy does NOT read this
|
|
40
40
|
* (see autoCompactRatio) — moving the policy's number is A1b's. */
|
|
41
|
+
/** OR-8 (owner, 2026-09-09): the status row names the effort next to the
|
|
42
|
+
* model — `gpt-5.6-sol · xhigh` — whenever the live binding carries one;
|
|
43
|
+
* the provider's default shows as the bare model, as before. The effort
|
|
44
|
+
* is the session's own (the durable profile), never the CLI's memory. */
|
|
45
|
+
export declare function statusModelLabel(session: {
|
|
46
|
+
readonly reasoning?: {
|
|
47
|
+
readonly effort: string;
|
|
48
|
+
};
|
|
49
|
+
}): string;
|
|
41
50
|
export declare function displayCtxRatio(session: AgentSession): number;
|
|
42
51
|
/** A1a: the number the auto-compact decision reads — the pre-A1a estimate,
|
|
43
52
|
* unchanged this round, named so a gate can pin that it did not move. */
|
|
@@ -161,6 +170,19 @@ export declare function parseSaferOptions(text: string): SaferOption[] | null;
|
|
|
161
170
|
* echo is UI, the chip is the record; the momentary double-render is
|
|
162
171
|
* the design's explicit point).
|
|
163
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;
|
|
164
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,
|
|
165
187
|
/** W21: the amend words ("Yes + feedback") ride the NEXT user turn —
|
|
166
188
|
* threaded from chat's submitTurn; absent in the recovery flow
|
|
@@ -176,6 +198,14 @@ export type ChatEnd = {
|
|
|
176
198
|
} | {
|
|
177
199
|
readonly next: "switch";
|
|
178
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;
|
|
179
209
|
};
|
|
180
210
|
/** The session-navigation seam main provides: the OTHER sessions'
|
|
181
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";
|
|
@@ -69,6 +69,14 @@ export function estimateCtxRatio(session) {
|
|
|
69
69
|
* their continuation envelopes, the output reserve when known) over the
|
|
70
70
|
* window. Still ~ (chars/4). The auto-compact policy does NOT read this
|
|
71
71
|
* (see autoCompactRatio) — moving the policy's number is A1b's. */
|
|
72
|
+
/** OR-8 (owner, 2026-09-09): the status row names the effort next to the
|
|
73
|
+
* model — `gpt-5.6-sol · xhigh` — whenever the live binding carries one;
|
|
74
|
+
* the provider's default shows as the bare model, as before. The effort
|
|
75
|
+
* is the session's own (the durable profile), never the CLI's memory. */
|
|
76
|
+
export function statusModelLabel(session) {
|
|
77
|
+
const effort = session.reasoning?.effort;
|
|
78
|
+
return effort !== undefined && effort !== "default" ? `${agentModel} · ${effort}` : agentModel;
|
|
79
|
+
}
|
|
72
80
|
export function displayCtxRatio(session) {
|
|
73
81
|
return requestBudget(session.requestParts(), contextWindowTokens()).ratio;
|
|
74
82
|
}
|
|
@@ -127,7 +135,24 @@ endpoint) {
|
|
|
127
135
|
const m = Math.min(prevTotal, total) - c.cacheRead;
|
|
128
136
|
missed = m > CACHE_MISS_FLOOR ? m : null;
|
|
129
137
|
}
|
|
130
|
-
|
|
138
|
+
// OR-10 (owner, 2026-09-09): a backend whose cache figure cannot be
|
|
139
|
+
// observed has no cache figure. The ChatGPT backend answers
|
|
140
|
+
// `cached_tokens` 0 to a third-party client on every request, so its 0
|
|
141
|
+
// and "not reported" are one value, and a CH 0% built on it claims a
|
|
142
|
+
// measurement that never happened. The registry ROW says which
|
|
143
|
+
// endpoints these are; the status row, the recap and the usage line
|
|
144
|
+
// already render a null cache as nothing ("an unmeasured cache is not
|
|
145
|
+
// a 0% cache"), so this is one decision at the one place the endpoint
|
|
146
|
+
// is known. The trace ledger is written by the runtime from the
|
|
147
|
+
// backend's own words and keeps its 0 — the display's rule, not the
|
|
148
|
+
// record's.
|
|
149
|
+
const unobservable = model !== undefined && lookupModelMetadata(model, endpoint)?.capabilities.promptCaching === "unobservable";
|
|
150
|
+
return {
|
|
151
|
+
usage: { in: c.input, out: c.output, cache: unobservable ? null : c.cacheRead, known: ev.known },
|
|
152
|
+
total,
|
|
153
|
+
missed: unobservable ? null : missed,
|
|
154
|
+
costUsd: c.costUsd,
|
|
155
|
+
};
|
|
131
156
|
}
|
|
132
157
|
/** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
|
|
133
158
|
* is gone) — docked only, 200ms rotation between the request and the
|
|
@@ -517,6 +542,37 @@ function toRenderInput(ev) {
|
|
|
517
542
|
* echo is UI, the chip is the record; the momentary double-render is
|
|
518
543
|
* the design's explicit point).
|
|
519
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
|
+
}
|
|
520
576
|
export async function consumeRun(session, run, input, turnNo, faux, statusCb,
|
|
521
577
|
/** W21: the amend words ("Yes + feedback") ride the NEXT user turn —
|
|
522
578
|
* threaded from chat's submitTurn; absent in the recovery flow
|
|
@@ -547,8 +603,16 @@ submitTurn) {
|
|
|
547
603
|
tailers.delete(callId);
|
|
548
604
|
};
|
|
549
605
|
try {
|
|
606
|
+
let modelTurns = 0;
|
|
550
607
|
for await (const ev of run) {
|
|
551
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
|
+
}
|
|
552
616
|
if (ev.type !== "thinking") {
|
|
553
617
|
if (thinkingSince !== null) {
|
|
554
618
|
thoughtSeconds += (Date.now() - thinkingSince) / 1000;
|
|
@@ -791,6 +855,17 @@ submitTurn) {
|
|
|
791
855
|
// is a sentence addressed to a human; it needs no edge.
|
|
792
856
|
body.notice('answer truncated at max_tokens — say "continue" to finish');
|
|
793
857
|
}
|
|
858
|
+
// OR-5 (the ChatGPT real leg, 2026-09-09): a run that ended in an
|
|
859
|
+
// ERROR terminal printed nothing but the recap — the vendor's 400
|
|
860
|
+
// lived in the durable log alone and the turn read as "took 1s".
|
|
861
|
+
// A failure that fires silently is indistinguishable from a
|
|
862
|
+
// crash, the one thing this product must never be: the mapped
|
|
863
|
+
// error is named here, before the recap, code and status and the
|
|
864
|
+
// provider's own words.
|
|
865
|
+
if (ev.outcome.kind === "error") {
|
|
866
|
+
const e = ev.outcome.error;
|
|
867
|
+
body.notice(`run failed — ${e.code}${e.status !== undefined ? ` ${e.status}` : ""}${e.retryable ? " (retryable)" : ""}: ${escapeTerminal(e.message)}`);
|
|
868
|
+
}
|
|
794
869
|
bodyLog(renderRecap({
|
|
795
870
|
seconds: Math.round((Date.now() - turnStart) / 1000),
|
|
796
871
|
// R3g: the work terms are NOT passed — the fold line
|
|
@@ -842,6 +917,9 @@ export async function chat(session, faux, input, autoCompact, nav) {
|
|
|
842
917
|
// the switch directive — set once by dispatch's /clear or /resume,
|
|
843
918
|
// resolved through the end signal so the final awaits still run
|
|
844
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;
|
|
845
923
|
let resolveEnd = () => { };
|
|
846
924
|
const endSignal = new Promise((r) => {
|
|
847
925
|
resolveEnd = r;
|
|
@@ -995,6 +1073,13 @@ export async function chat(session, faux, input, autoCompact, nav) {
|
|
|
995
1073
|
exitAtEmptyPrompt();
|
|
996
1074
|
});
|
|
997
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
|
+
}
|
|
998
1083
|
if (currentRun) {
|
|
999
1084
|
console.log("\n[aborting run]");
|
|
1000
1085
|
pendingAsk?.();
|
|
@@ -1083,7 +1168,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
|
|
|
1083
1168
|
// TUI2-R1 (E): the meter rides the idle row — both fields omitted
|
|
1084
1169
|
// when unknown, so a session that has not called the model paints
|
|
1085
1170
|
// exactly the pre-round row.
|
|
1086
|
-
dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(),
|
|
1171
|
+
dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), statusModelLabel(session), displayCtxRatio(session), {
|
|
1087
1172
|
cacheHitPct: cacheHitPct(runUsage),
|
|
1088
1173
|
costUsd: spentUsd,
|
|
1089
1174
|
}));
|
|
@@ -1101,6 +1186,17 @@ export async function chat(session, faux, input, autoCompact, nav) {
|
|
|
1101
1186
|
// a turn ended. One call, and the SAME formatter — a boot-time copy of
|
|
1102
1187
|
// the row would drift from the real one the moment either changed.
|
|
1103
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
|
+
};
|
|
1104
1200
|
const statusCb = (u, ctx, costUsd) => {
|
|
1105
1201
|
runUsage = u;
|
|
1106
1202
|
addCost(costUsd ?? null);
|
|
@@ -1171,6 +1267,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
|
|
|
1171
1267
|
chainRef,
|
|
1172
1268
|
isRunning: () => currentRun !== null,
|
|
1173
1269
|
paintIdle,
|
|
1270
|
+
modelSwitched,
|
|
1174
1271
|
submitTurn,
|
|
1175
1272
|
estimateCtx: () => displayCtxRatio(session),
|
|
1176
1273
|
contextWindow: () => contextWindowTokens(),
|
|
@@ -1181,6 +1278,10 @@ export async function chat(session, faux, input, autoCompact, nav) {
|
|
|
1181
1278
|
switchTo = id;
|
|
1182
1279
|
resolveEnd();
|
|
1183
1280
|
},
|
|
1281
|
+
requestReload: () => {
|
|
1282
|
+
reloadReq = true;
|
|
1283
|
+
resolveEnd();
|
|
1284
|
+
},
|
|
1184
1285
|
sessions: () => nav?.sessions() ?? [],
|
|
1185
1286
|
...(nav?.pick !== undefined ? { pickSession: nav.pick } : {}),
|
|
1186
1287
|
};
|
|
@@ -1220,6 +1321,8 @@ export async function chat(session, faux, input, autoCompact, nav) {
|
|
|
1220
1321
|
// chain action (the sentinel's control char marks the key, so a typed
|
|
1221
1322
|
// "expand" turn is never intercepted).
|
|
1222
1323
|
input.onExpand(() => dispatch("\x12expand", dispatchCtx));
|
|
1324
|
+
input.onThink?.(() => dispatch("\x14think", dispatchCtx));
|
|
1325
|
+
input.onEditor?.(() => dispatch("\x07editor", dispatchCtx));
|
|
1223
1326
|
// E1 §3 — ctrl+x and `/copy` are the same action reached two ways, so
|
|
1224
1327
|
// they are the same sentinel: one implementation, one behaviour.
|
|
1225
1328
|
input.onCopy(() => dispatch("\x18copy", dispatchCtx));
|
|
@@ -1287,5 +1390,9 @@ export async function chat(session, faux, input, autoCompact, nav) {
|
|
|
1287
1390
|
// runs before the exit or the chain is already settled. One level is
|
|
1288
1391
|
// enough: the /compact segment appends nothing of its own.
|
|
1289
1392
|
await chainRef.current;
|
|
1290
|
-
|
|
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" };
|
|
1291
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
|
@@ -31,6 +31,7 @@ import { readFileSync } from "node:fs";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { kisoHome } from "./state.js";
|
|
33
33
|
import { AuthError, getCredential, providerIdOf } from "./auth/credentials.js";
|
|
34
|
+
import { BUILTIN_MANIFESTS } from "@vincemakes/kiso-runtime/internal";
|
|
34
35
|
const KINDS = ["openai-compat", "anthropic", "openai-responses"];
|
|
35
36
|
const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass"];
|
|
36
37
|
export class ConfigError extends Error {
|
|
@@ -99,12 +100,15 @@ export function parseConfig(text, source) {
|
|
|
99
100
|
fail(`models.${name}.baseUrl`, "expected a string");
|
|
100
101
|
if (p.promptCaching !== undefined && typeof p.promptCaching !== "boolean")
|
|
101
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)");
|
|
102
105
|
models[name] = {
|
|
103
106
|
kind: p.kind,
|
|
104
107
|
model: p.model,
|
|
105
108
|
...(typeof p.apiKeyEnv === "string" ? { apiKeyEnv: p.apiKeyEnv } : {}),
|
|
106
109
|
...(typeof p.baseUrl === "string" ? { baseUrl: p.baseUrl } : {}),
|
|
107
110
|
...(typeof p.promptCaching === "boolean" ? { promptCaching: p.promptCaching } : {}),
|
|
111
|
+
...(typeof p.streamIdleMs === "number" ? { streamIdleMs: p.streamIdleMs } : {}),
|
|
108
112
|
};
|
|
109
113
|
}
|
|
110
114
|
out.models = models;
|
|
@@ -209,6 +213,15 @@ export function authForProfile(name, p) {
|
|
|
209
213
|
throw new ConfigError(`model ${name}: signed in to ${providerId} with OAuth, but this profile's adapter needs an API key — run \`kiso login ${providerId}\` with a key, or \`kiso logout ${providerId}\` to use the env var ${p.apiKeyEnv ?? "(none configured)"}`);
|
|
210
214
|
}
|
|
211
215
|
}
|
|
216
|
+
// OR-2 (2026-09-09): a provider whose manifest takes NO API key (the ChatGPT
|
|
217
|
+
// backend: authMethods ["oauth"]) is never an "unauthenticated endpoint" —
|
|
218
|
+
// with nothing stored it is a profile waiting for its sign-in. Without this
|
|
219
|
+
// the keyless rule below handed it a placeholder key, `/model` said
|
|
220
|
+
// "available", and the adapter (an apiKey selects the first-party target)
|
|
221
|
+
// would have sent an unauthenticated request to the wrong URL.
|
|
222
|
+
if (providerId !== null && !manifestTakesKey(providerId)) {
|
|
223
|
+
throw new ConfigError(`model ${name}: unavailable — not signed in: run \`kiso login ${providerId}\` (this provider takes no API key)`);
|
|
224
|
+
}
|
|
212
225
|
if (p.apiKeyEnv === undefined)
|
|
213
226
|
return { type: "api-key", apiKey: "none", source: "none" };
|
|
214
227
|
const fromEnv = process.env[p.apiKeyEnv];
|
|
@@ -217,6 +230,13 @@ export function authForProfile(name, p) {
|
|
|
217
230
|
const hint = providerId !== null ? `run \`kiso login ${providerId}\` or set the env var ${p.apiKeyEnv}` : `set the env var ${p.apiKeyEnv}`;
|
|
218
231
|
throw new ConfigError(`model ${name}: unavailable — no credential: ${hint} (configs never store keys, only the env-var name)`);
|
|
219
232
|
}
|
|
233
|
+
/** OR-2: does the provider's manifest admit an API key (or no auth at all)?
|
|
234
|
+
* An unknown id answers yes — the keyless-endpoint rule stays for custom
|
|
235
|
+
* origins; only a manifest that says OAuth-only takes the sign-in path. */
|
|
236
|
+
function manifestTakesKey(providerId) {
|
|
237
|
+
const m = BUILTIN_MANIFESTS.find((x) => x.id === providerId);
|
|
238
|
+
return m === undefined || m.authMethods.includes("api-key") || m.authMethods.includes("none");
|
|
239
|
+
}
|
|
220
240
|
/** The reason a profile is unavailable, for the surfaces that print it. */
|
|
221
241
|
export function unavailableReason(name, p) {
|
|
222
242
|
try {
|
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;
|