@vincemakes/kiso-code 0.8.0 → 0.10.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/chat.d.ts +6 -1
- package/dist/chat.js +123 -10
- package/dist/dispatch.d.ts +3 -0
- package/dist/dispatch.js +76 -6
- package/dist/index.js +127 -8
- package/dist/session-cards.d.ts +100 -0
- package/dist/session-cards.js +95 -0
- package/dist/state.d.ts +35 -3
- package/dist/state.js +75 -2
- package/package.json +14 -14
package/dist/chat.d.ts
CHANGED
|
@@ -45,6 +45,10 @@ export interface UsageDelta {
|
|
|
45
45
|
* recovers on the next known event). */
|
|
46
46
|
readonly total: number | null;
|
|
47
47
|
readonly missed: number | null;
|
|
48
|
+
/** TUI2-R1 (E): the CANONICAL cost of this request — null when the
|
|
49
|
+
* pricing table has no rate for the route (the R5b-④c absent stamp).
|
|
50
|
+
* Null is carried, never zeroed: a missing rate is not a free call. */
|
|
51
|
+
readonly costUsd: number | null;
|
|
48
52
|
}
|
|
49
53
|
/**
|
|
50
54
|
* The CLI's usage consumer (E2 1.3.0, the R2a-1 ruling 2026-08-13) — the
|
|
@@ -76,6 +80,7 @@ export declare function usageFromEvent(route: string | undefined, ev: import("@v
|
|
|
76
80
|
* is gone) — docked only, 200ms rotation between the request and the
|
|
77
81
|
* first event. */
|
|
78
82
|
export declare function startStatusSpinner(onTick: (glyph: string) => void): () => void;
|
|
83
|
+
export declare function startShellTail(sessionId: string, callId: string, command: string, startedAt: number): () => void;
|
|
79
84
|
/**
|
|
80
85
|
* Consume a run, answering approval pauses as they arrive. `resumeMode`
|
|
81
86
|
* marks a session.resume() continuation. `faux` picks the status line's
|
|
@@ -84,7 +89,7 @@ export declare function startStatusSpinner(onTick: (glyph: string) => void): ()
|
|
|
84
89
|
* echo is UI, the chip is the record; the momentary double-render is
|
|
85
90
|
* the design's explicit point).
|
|
86
91
|
*/
|
|
87
|
-
export declare function consumeRun(session: AgentSession, run: Run, input: LineInput, turnNo: number, faux: boolean, statusCb: ((usage: RunUsage, ctxRatio: number) => void) | null,
|
|
92
|
+
export declare function consumeRun(session: AgentSession, run: Run, input: LineInput, turnNo: number, faux: boolean, statusCb: ((usage: RunUsage, ctxRatio: number, costUsd?: number | null) => void) | null,
|
|
88
93
|
/** W21: the amend words ("Yes + feedback") ride the NEXT user turn —
|
|
89
94
|
* threaded from chat's submitTurn; absent in the recovery flow
|
|
90
95
|
* (resume) where a dropped amend is noticed instead. */
|
package/dist/chat.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* approval-moment mini-diff, the status spinner, and the context
|
|
5
5
|
* estimates. All bodies moved verbatim from index.ts.
|
|
6
6
|
*/
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
|
-
import { escapeTerminal, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
|
|
7
|
+
import { readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { escapeTerminal, cacheHitPct, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, STATUS_GLYPHS, } from "@vincemakes/kiso-tui";
|
|
9
9
|
import { editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
|
|
10
|
-
import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
|
|
10
|
+
import { canonicalTargetPath, shellProgressPath } from "@vincemakes/kiso-tools-node";
|
|
11
11
|
import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
|
|
12
12
|
import { dispatch } from "./dispatch.js";
|
|
13
13
|
import { agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
|
|
@@ -89,7 +89,7 @@ export function usageFromEvent(route, ev, prevTotal) {
|
|
|
89
89
|
const m = Math.min(prevTotal, total) - c.cacheRead;
|
|
90
90
|
missed = m > CACHE_MISS_FLOOR ? m : null;
|
|
91
91
|
}
|
|
92
|
-
return { usage: { in: c.input, out: c.output, cache: c.cacheRead, known: ev.known }, total, missed };
|
|
92
|
+
return { usage: { in: c.input, out: c.output, cache: c.cacheRead, known: ev.known }, total, missed, costUsd: c.costUsd };
|
|
93
93
|
}
|
|
94
94
|
/** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
|
|
95
95
|
* is gone) — docked only, 200ms rotation between the request and the
|
|
@@ -105,6 +105,45 @@ export function startStatusSpinner(onTick) {
|
|
|
105
105
|
timer.unref();
|
|
106
106
|
return () => clearInterval(timer);
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* TUI2-R1 (C) — the shell tailer: the READER half of the progress
|
|
110
|
+
* sidecar (the writer is the shell tool, tools-node).
|
|
111
|
+
*
|
|
112
|
+
* The CLI is the only place that holds both facts the derived key needs
|
|
113
|
+
* — the session's id and the running call's command — so the tail is
|
|
114
|
+
* read here and handed to the cell. A poll, not a watcher: fs.watch's
|
|
115
|
+
* behaviour on a file being appended to differs by platform, and the
|
|
116
|
+
* one thing this must never do is misbehave in a way that costs the run.
|
|
117
|
+
*
|
|
118
|
+
* THE FRESHNESS GUARD is the part that makes a kill -9 leftover
|
|
119
|
+
* harmless. A sidecar the writer never got to remove keeps its old
|
|
120
|
+
* mtime; a tail is read only from a file modified AT OR AFTER the call
|
|
121
|
+
* started. A ghost from a previous process cannot be shown as this
|
|
122
|
+
* call's output — and since the tail is display-only, showing nothing is
|
|
123
|
+
* always the safe answer.
|
|
124
|
+
*/
|
|
125
|
+
const TAIL_POLL_MS = 250;
|
|
126
|
+
const TAIL_BYTES = 4096; // the last lines are all the window can hold
|
|
127
|
+
export function startShellTail(sessionId, callId, command, startedAt) {
|
|
128
|
+
const path = shellProgressPath(sessionId, command);
|
|
129
|
+
const read = () => {
|
|
130
|
+
try {
|
|
131
|
+
const stat = statSync(path);
|
|
132
|
+
if (stat.mtimeMs + 1000 < startedAt)
|
|
133
|
+
return; // a ghost from a killed run — never this call's
|
|
134
|
+
const text = readFileSync(path, "utf8");
|
|
135
|
+
body.toolProgress(callId, text.slice(-TAIL_BYTES).trimEnd());
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// no sidecar yet, removed at settle, or unreadable — the tail
|
|
139
|
+
// is an observation, and its absence is never an error
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
read();
|
|
143
|
+
const timer = setInterval(read, TAIL_POLL_MS);
|
|
144
|
+
timer.unref();
|
|
145
|
+
return () => clearInterval(timer);
|
|
146
|
+
}
|
|
108
147
|
/** v2e: the approval-moment mini-diff — edit_file/write_file changes as
|
|
109
148
|
* ± lines; other tools get null (no diff, no cost). The file read is
|
|
110
149
|
* best-effort: an unreadable file yields NO diff, never a failure —
|
|
@@ -128,7 +167,9 @@ function approvalDiff(name, input) {
|
|
|
128
167
|
const replace = typeof input.replace === "string" ? input.replace : "";
|
|
129
168
|
if (search === "")
|
|
130
169
|
return null;
|
|
131
|
-
|
|
170
|
+
// TUI2-R1.5 ② (VD-2): the path rides along so a miss can name the
|
|
171
|
+
// file in its honest note instead of fabricating a diff.
|
|
172
|
+
return editFileDiff(oldContent ?? "", search, replace, path);
|
|
132
173
|
}
|
|
133
174
|
const content = typeof input.content === "string" ? input.content : "";
|
|
134
175
|
return writeFileDiff(oldContent, content);
|
|
@@ -268,6 +309,16 @@ submitTurn) {
|
|
|
268
309
|
// at the first non-thinking event (the fold needs the seconds).
|
|
269
310
|
let thoughtSeconds = 0;
|
|
270
311
|
let thinkingSince = null;
|
|
312
|
+
// TUI2-R1 (C): the shell commands seen this run, and the tailers
|
|
313
|
+
// running for them. A tailer is started when the execution starts and
|
|
314
|
+
// stopped at the call's result — and the finally below stops any that
|
|
315
|
+
// an abort left behind, so a poller can never outlive its run.
|
|
316
|
+
const shellCommands = new Map();
|
|
317
|
+
const tailers = new Map();
|
|
318
|
+
const stopTail = (callId) => {
|
|
319
|
+
tailers.get(callId)?.();
|
|
320
|
+
tailers.delete(callId);
|
|
321
|
+
};
|
|
271
322
|
try {
|
|
272
323
|
for await (const ev of run) {
|
|
273
324
|
last = ev;
|
|
@@ -295,10 +346,18 @@ submitTurn) {
|
|
|
295
346
|
if (ev.name === "edit_file")
|
|
296
347
|
editCount += 1;
|
|
297
348
|
body.toolStart(ev.name, ev.callId, ev.input ?? {});
|
|
349
|
+
// TUI2-R1 (C): the command is the sidecar key's other half —
|
|
350
|
+
// remembered here, used when the execution actually starts.
|
|
351
|
+
if (ev.name === "shell" && typeof ev.input?.command === "string")
|
|
352
|
+
shellCommands.set(ev.callId, ev.input.command);
|
|
298
353
|
break;
|
|
299
|
-
case "tool_execution_started":
|
|
354
|
+
case "tool_execution_started": {
|
|
300
355
|
body.toolRunning(ev.callId);
|
|
356
|
+
const command = shellCommands.get(ev.callId);
|
|
357
|
+
if (command !== undefined)
|
|
358
|
+
tailers.set(ev.callId, startShellTail(session.id, ev.callId, command, Date.now()));
|
|
301
359
|
break;
|
|
360
|
+
}
|
|
302
361
|
case "tool_execution_succeeded":
|
|
303
362
|
body.toolSucceeded(ev.callId);
|
|
304
363
|
break;
|
|
@@ -306,6 +365,9 @@ submitTurn) {
|
|
|
306
365
|
body.toolFailed(ev.callId, ev.error);
|
|
307
366
|
break;
|
|
308
367
|
case "tool_result": {
|
|
368
|
+
// TUI2-R1 (C): the observation window closes the instant the
|
|
369
|
+
// real result exists — the tail must never race it.
|
|
370
|
+
stopTail(ev.callId);
|
|
309
371
|
const text = typeof ev.content === "string" ? ev.content : "";
|
|
310
372
|
// W19: a DENIED call carries its reason — extracted from the
|
|
311
373
|
// result's "[Permission denied] " prefix, keyed on the
|
|
@@ -340,7 +402,9 @@ submitTurn) {
|
|
|
340
402
|
usage = delta.usage;
|
|
341
403
|
prevTotal = delta.total;
|
|
342
404
|
missed = delta.missed;
|
|
343
|
-
|
|
405
|
+
// TUI2-R1 (E): the request's canonical cost rides the same
|
|
406
|
+
// callback the usage does — one settled request, one addition.
|
|
407
|
+
statusCb?.(usage, estimateCtxRatio(session), delta.costUsd);
|
|
344
408
|
break;
|
|
345
409
|
}
|
|
346
410
|
case "uncertain_pending":
|
|
@@ -471,6 +535,11 @@ submitTurn) {
|
|
|
471
535
|
body.thinkingEnd(); // a trailing thinking block folds at the run's end
|
|
472
536
|
}
|
|
473
537
|
finally {
|
|
538
|
+
// TUI2-R1 (C): an abort or a throw leaves the loop without a
|
|
539
|
+
// tool_result — every tailer stops here regardless, so no poller
|
|
540
|
+
// outlives the run that started it.
|
|
541
|
+
for (const callId of [...tailers.keys()])
|
|
542
|
+
stopTail(callId);
|
|
474
543
|
}
|
|
475
544
|
return last;
|
|
476
545
|
}
|
|
@@ -639,6 +708,11 @@ export async function chat(session, faux, input, autoCompact) {
|
|
|
639
708
|
// glyph (▖▘▝▗ — the spinner drives it) + wall seconds + ↓ out tokens
|
|
640
709
|
// + the interrupt hint. ctx left is the live estimate everywhere.
|
|
641
710
|
let runUsage = { in: null, out: null, cache: null, known: false };
|
|
711
|
+
// TUI2-R1 (E): the session's spend so far — the CANONICAL cost of every
|
|
712
|
+
// request this process has seen, summed. Null stays null: a route with
|
|
713
|
+
// no rate in the pricing table contributes nothing and the row shows no
|
|
714
|
+
// $ at all, because a partial total presented as a total is a lie.
|
|
715
|
+
let spentUsd = null;
|
|
642
716
|
let runGlyph = "▖";
|
|
643
717
|
let runStart = Date.now();
|
|
644
718
|
// KC2 §5: the STATE (the glyph, the run's start, the usage, the dock)
|
|
@@ -651,13 +725,42 @@ export async function chat(session, faux, input, autoCompact) {
|
|
|
651
725
|
// parentheses idiom names the read-only constraint. The tier is the
|
|
652
726
|
// CALLER's word (the recovery flow passes the bare mode).
|
|
653
727
|
const paintIdle = () => {
|
|
654
|
-
if (dock.active)
|
|
655
|
-
|
|
728
|
+
if (!dock.active)
|
|
729
|
+
return;
|
|
730
|
+
// TUI2-R1 (E): the meter rides the idle row — both fields omitted
|
|
731
|
+
// when unknown, so a session that has not called the model paints
|
|
732
|
+
// exactly the pre-round row.
|
|
733
|
+
dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), agentModel, estimateCtxRatio(session), {
|
|
734
|
+
cacheHitPct: cacheHitPct(runUsage),
|
|
735
|
+
costUsd: spentUsd,
|
|
736
|
+
}));
|
|
656
737
|
};
|
|
657
|
-
|
|
738
|
+
// TUI2-R2 ⑥ — the BOOT status line. The row is the product's one
|
|
739
|
+
// persistent claim about itself (the tier, how to change it, the model,
|
|
740
|
+
// the context left), and it used to appear after turn ONE: the
|
|
741
|
+
// idle-fresh screen — the screen every session opens on — showed an
|
|
742
|
+
// empty row where all of that belongs.
|
|
743
|
+
//
|
|
744
|
+
// Nothing had to be computed to fix it. paintIdle already had every
|
|
745
|
+
// field at this point: the mode is set before the agent is built, the
|
|
746
|
+
// model is resolved inside it, and an unstarted session's context
|
|
747
|
+
// estimate is a perfectly good 100%. It was simply never called until
|
|
748
|
+
// a turn ended. One call, and the SAME formatter — a boot-time copy of
|
|
749
|
+
// the row would drift from the real one the moment either changed.
|
|
750
|
+
paintIdle();
|
|
751
|
+
const statusCb = (u, ctx, costUsd) => {
|
|
658
752
|
runUsage = u;
|
|
753
|
+
addCost(costUsd ?? null);
|
|
659
754
|
paintRunning();
|
|
660
755
|
};
|
|
756
|
+
// TUI2-R1 (E): the canonical cost of one settled request, added to the
|
|
757
|
+
// session's running total. A null cost (no rate for the route) adds
|
|
758
|
+
// nothing and leaves the total as it was.
|
|
759
|
+
const addCost = (usd) => {
|
|
760
|
+
if (usd === null)
|
|
761
|
+
return;
|
|
762
|
+
spentUsd = (spentUsd ?? 0) + usd;
|
|
763
|
+
};
|
|
661
764
|
const submitTurn = (line) => {
|
|
662
765
|
const slot = { line, cancelled: false };
|
|
663
766
|
pendingTurns.push(slot);
|
|
@@ -717,6 +820,7 @@ export async function chat(session, faux, input, autoCompact) {
|
|
|
717
820
|
paintIdle,
|
|
718
821
|
submitTurn,
|
|
719
822
|
estimateCtx: () => estimateCtxRatio(session),
|
|
823
|
+
contextWindow: () => contextWindowTokens(),
|
|
720
824
|
};
|
|
721
825
|
// the ergonomics batch C8: the auto-compact check — the /compact FULL path via the
|
|
722
826
|
// shared dispatch (same notices, same chain ordering, same mid-run
|
|
@@ -756,6 +860,15 @@ export async function chat(session, faux, input, autoCompact) {
|
|
|
756
860
|
const recoveryRun = session.resume();
|
|
757
861
|
currentRun = recoveryRun;
|
|
758
862
|
turnNo += 1;
|
|
863
|
+
// TUI2-R1.5 ③ (VD-3 family): stamp the run's start AT the run's
|
|
864
|
+
// entry. Every other run path does; this one inherited the value
|
|
865
|
+
// from the process's own startup, so its "working Ns" was the
|
|
866
|
+
// session's age rather than the recovery's. The drift is small
|
|
867
|
+
// today (recovery follows startup closely) and unbounded in
|
|
868
|
+
// principle — a slow MCP connect is seconds the recovery never
|
|
869
|
+
// spent, reported as seconds it did.
|
|
870
|
+
runStart = Date.now();
|
|
871
|
+
runUsage = { in: null, out: null, cache: null, known: false };
|
|
759
872
|
const last = await consumeRun(session, recoveryRun, input, turnNo, faux, statusCb, submitTurn);
|
|
760
873
|
currentRun = null;
|
|
761
874
|
failOnFauxExhaustion(last, faux, input);
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ export interface DispatchCtx {
|
|
|
22
22
|
readonly submitTurn: (line: string) => void;
|
|
23
23
|
/** the /status context estimate. */
|
|
24
24
|
readonly estimateCtx: () => number;
|
|
25
|
+
/** TUI2-R1 (E): the model's context window, as the session is
|
|
26
|
+
* configured — the /context ledger's denominator. */
|
|
27
|
+
readonly contextWindow: () => number;
|
|
25
28
|
}
|
|
26
29
|
/** The ONE dispatcher — slash commands, exit, and turns. The recovery
|
|
27
30
|
* replay routes through it too — a queued "/last" must never become a
|
package/dist/dispatch.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* turns. The bodies moved verbatim from chat()'s closure; chat provides
|
|
4
4
|
* the context (the chain, the run state, the prompt arming).
|
|
5
5
|
*/
|
|
6
|
-
import { escapeTerminal, helpRows, kUnit, palette } from "@vincemakes/kiso-tui";
|
|
6
|
+
import { contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modelPickView, palette } from "@vincemakes/kiso-tui";
|
|
7
7
|
import { buildAdapter } from "@vincemakes/kiso-runtime/internal";
|
|
8
8
|
import { MODES, getMode, setMode } from "./mode.js";
|
|
9
|
-
import { agentModel, body, bodyLog, configModels, dock, setAgentModel, setCurrentModelName } from "./state.js";
|
|
9
|
+
import { agentModel, body, bodyLog, configModels, dock, readContextLedger, setAgentModel, setCurrentModelName } from "./state.js";
|
|
10
10
|
import { directWriteProfile, profileAvailable } from "./config.js";
|
|
11
11
|
/** The ONE dispatcher — slash commands, exit, and turns. The recovery
|
|
12
12
|
* replay routes through it too — a queued "/last" must never become a
|
|
@@ -23,8 +23,10 @@ export function dispatch(line, ctx) {
|
|
|
23
23
|
// the last one still carries its own \n, so `exit` and `keys`
|
|
24
24
|
// land as two rows from one call (bodyLog splits on \n).
|
|
25
25
|
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
26
|
+
// TUI2-R1.5 9 (VD-10): /help is sentences for a human — the keys
|
|
27
|
+
// row in particular is one long line that hard-folded mid-word.
|
|
26
28
|
for (const row of helpRows())
|
|
27
|
-
bodyLog(row);
|
|
29
|
+
bodyLog(row, "words");
|
|
28
30
|
ctx.input.prompt();
|
|
29
31
|
});
|
|
30
32
|
return;
|
|
@@ -54,9 +56,21 @@ export function dispatch(line, ctx) {
|
|
|
54
56
|
bodyLog("[no tool call yet]");
|
|
55
57
|
}
|
|
56
58
|
else {
|
|
57
|
-
|
|
59
|
+
// TUI2-R2pre ④: the SECTION HEADERS say the act ("--- read
|
|
60
|
+
// input ---") on the interactive SCREEN; the two payloads
|
|
61
|
+
// between them are the RAW input JSON and the RAW result
|
|
62
|
+
// content and are byte-identical either way.
|
|
63
|
+
//
|
|
64
|
+
// The dock check is the round's one conditional, and it is
|
|
65
|
+
// deliberate: /last is a single code path serving two
|
|
66
|
+
// surfaces, and the ruling covers the screen while the pipe
|
|
67
|
+
// is a machine-readable log whose bytes other things (the
|
|
68
|
+
// e2e gates, anyone's script) already depend on. Without the
|
|
69
|
+
// dock, this is that log — so it keeps the call's own name.
|
|
70
|
+
const verb = dock.active ? displayVerb(tool.name) : tool.name;
|
|
71
|
+
bodyLog(`--- ${verb} input ---`);
|
|
58
72
|
bodyLog(escapeTerminal(JSON.stringify(tool.input, null, 2)));
|
|
59
|
-
bodyLog(`--- ${
|
|
73
|
+
bodyLog(`--- ${verb} output${tool.result.isError ? " (error)" : ""} ---`);
|
|
60
74
|
bodyLog(escapeTerminal(tool.result.content));
|
|
61
75
|
}
|
|
62
76
|
ctx.input.prompt();
|
|
@@ -92,6 +106,30 @@ export function dispatch(line, ctx) {
|
|
|
92
106
|
ctx.chainRef.current = ctx.chainRef.current.then(land);
|
|
93
107
|
return;
|
|
94
108
|
}
|
|
109
|
+
if (trimmed === "/context") {
|
|
110
|
+
// TUI2-R1 (E): the rent-ledger attribution — where the context went,
|
|
111
|
+
// read from the session's TRACE SIDECAR (the observation file E1/E3
|
|
112
|
+
// already write, per request).
|
|
113
|
+
//
|
|
114
|
+
// THE PURITY GATE IS UNTOUCHED and this is why: the trace surface is
|
|
115
|
+
// an OBSERVATION surface (ADR-0051 §6, ruling R7) and correctness
|
|
116
|
+
// never reads it. /context is a DISPLAY command — nothing it reads
|
|
117
|
+
// reaches a recovery plan, a projection, or a request. The read is
|
|
118
|
+
// best-effort by construction: a missing, partial or unparseable
|
|
119
|
+
// ledger renders the honest fallback, never an error and never a
|
|
120
|
+
// guess. recovery-purity.test.ts's probes are unaffected: the
|
|
121
|
+
// derivation still does zero I/O and still ignores trace-shaped data.
|
|
122
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
123
|
+
const ledger = readContextLedger(ctx.session.id, ctx.contextWindow());
|
|
124
|
+
for (const row of ledger === null
|
|
125
|
+
? contextUnavailableRows("the ledger is written per request — run a turn, then ask again")
|
|
126
|
+
: contextRows(ledger)) {
|
|
127
|
+
bodyLog(row);
|
|
128
|
+
}
|
|
129
|
+
ctx.input.prompt();
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
95
133
|
if (trimmed === "/status") {
|
|
96
134
|
// B area: session id, durable event count, and the ~ context
|
|
97
135
|
// estimate — all read straight from the live session, nothing
|
|
@@ -136,7 +174,39 @@ export function dispatch(line, ctx) {
|
|
|
136
174
|
// session's adapter — the NEXT turn uses it (session.setAdapter),
|
|
137
175
|
// the notice cell leaves the audit line in the body.
|
|
138
176
|
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
139
|
-
|
|
177
|
+
let arg = trimmed.slice(6).trim();
|
|
178
|
+
// TUI2-R2 ④ — bare /model PICKS. It used to print a list and a
|
|
179
|
+
// sentence telling you to go and edit a JSON file: everything
|
|
180
|
+
// needed to make it a choice was already on screen, and only the
|
|
181
|
+
// choosing was missing. The panel adds the choosing and nothing
|
|
182
|
+
// else — what a switch MEANS is unchanged below, and the printed
|
|
183
|
+
// list survives verbatim wherever there is no panel to draw (a
|
|
184
|
+
// pipe, a dock-less TTY), because that is a machine-readable
|
|
185
|
+
// surface and this round moves no bytes on one.
|
|
186
|
+
if (arg === "" && dock.active && ctx.input.panelAsk !== undefined) {
|
|
187
|
+
const names = Object.keys(configModels);
|
|
188
|
+
const picked = await new Promise((resolve) => {
|
|
189
|
+
ctx.input.panelAsk(modelPickView({
|
|
190
|
+
header: `model — current: ${agentModel}`,
|
|
191
|
+
options: names.map((name) => {
|
|
192
|
+
const profile = configModels[name];
|
|
193
|
+
const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : [])];
|
|
194
|
+
return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · ") };
|
|
195
|
+
}),
|
|
196
|
+
typeHint: names.length === 0 ? "type provider/model directly (e.g. openai/deepseek-reasoner)" : "type provider/model directly",
|
|
197
|
+
// the zero-profile copy is TODAY'S, verbatim: the
|
|
198
|
+
// user who sees it is exactly the user who needs
|
|
199
|
+
// the path spelled out
|
|
200
|
+
...(names.length === 0 ? { emptyNote: "no profiles — define models in ~/.kiso/config.json" } : {}),
|
|
201
|
+
}, ctx.isRunning() ? "▸ run paused" : `▸ ${getMode()}`), (v) => resolve(v.action === "picked" ? v.result : null));
|
|
202
|
+
});
|
|
203
|
+
ctx.paintIdle();
|
|
204
|
+
if (picked === null) {
|
|
205
|
+
ctx.input.prompt();
|
|
206
|
+
return; // esc — nothing switched, nothing said
|
|
207
|
+
}
|
|
208
|
+
arg = "index" in picked ? names[picked.index] : picked.custom;
|
|
209
|
+
}
|
|
140
210
|
if (arg === "") {
|
|
141
211
|
bodyLog(`model: ${agentModel}`);
|
|
142
212
|
const names = Object.keys(configModels);
|
package/dist/index.js
CHANGED
|
@@ -26,19 +26,20 @@ import { readFileSync, realpathSync, rmSync } from "node:fs";
|
|
|
26
26
|
import { createInterface } from "node:readline";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { join } from "node:path";
|
|
29
|
-
import { Body, Editor, bannerLines, escapeTerminal, extensionsBannerText, interactivePrompt, palette, renderSessionLine } from "@vincemakes/kiso-tui";
|
|
29
|
+
import { Body, Editor, bannerLines, escapeTerminal, extensionsBannerText, idColumn, idleStatus, interactivePrompt, palette, renderSessionLine, sessionListFooter, sessionListRow } from "@vincemakes/kiso-tui";
|
|
30
30
|
import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, SessionStore, } from "@vincemakes/kiso-runtime";
|
|
31
31
|
import { createFauxProvider } from "@vincemakes/kiso-evals";
|
|
32
32
|
import { createCodingTools } from "@vincemakes/kiso-tools-node";
|
|
33
|
-
import { MODES, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
|
|
33
|
+
import { MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
|
|
34
34
|
import { builtInLayer } from "./builtin.js";
|
|
35
|
-
import { atFiles, body, bodyLog, builtInExtensions, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, projectExtensions, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, userExtensions, VERSION } from "./state.js";
|
|
35
|
+
import { agentModel, atFiles, body, bodyLog, builtInExtensions, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, projectExtensions, sessionStoreRef, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, setSessionStore, userExtensions, VERSION } from "./state.js";
|
|
36
36
|
import { askUi, resolveProjectTrust } from "./trust-ui.js";
|
|
37
37
|
import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
|
|
38
38
|
import { fauxSkip, readFauxScript } from "./faux-glue.js";
|
|
39
|
-
import { autoCompactFromEnv, chat, contextWindowTokens } from "./chat.js";
|
|
39
|
+
import { autoCompactFromEnv, chat, contextWindowTokens, estimateCtxRatio } from "./chat.js";
|
|
40
40
|
import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
|
|
41
41
|
import { resume } from "./resume.js";
|
|
42
|
+
import { collectSessionCards } from "./session-cards.js";
|
|
42
43
|
// The moved exports stay reachable from this entry — the test imports
|
|
43
44
|
// (project-trust, coding-agent) never change (B4: zero assertion changes).
|
|
44
45
|
export { applyProjectMerges } from "./trust-ui.js";
|
|
@@ -159,6 +160,12 @@ function editorInput(editor) {
|
|
|
159
160
|
panelCancel() {
|
|
160
161
|
editor.cancelPanel();
|
|
161
162
|
},
|
|
163
|
+
// TUI2-R2 ②: the session picker — the editor owns the keys (the
|
|
164
|
+
// selection walk, the filter, enter/esc), the compositor draws the
|
|
165
|
+
// band, and the id comes back here.
|
|
166
|
+
pick(cards, onPick) {
|
|
167
|
+
editor.beginPick(cards, onPick);
|
|
168
|
+
},
|
|
162
169
|
// W22: the pending-turn queue — the ↑ pop walk (the keys); the
|
|
163
170
|
// chips are the compositor's bindQueue (the dock side).
|
|
164
171
|
bindQueue(state, pop) {
|
|
@@ -199,6 +206,8 @@ function makeLineInput() {
|
|
|
199
206
|
editor.bindAtItems(atFiles); // KC3 §5: the file source — listed per OPEN
|
|
200
207
|
dock.bindAt(() => editor.atState()); // KC3 §4: the picker's band
|
|
201
208
|
dock.bindApproval(() => editor.panelState()); // W21: the panel's bound state
|
|
209
|
+
dock.bindSheet(() => editor.sheetOpen()); // TUI2-R1 (D): the ? keys sheet
|
|
210
|
+
dock.bindPick(() => editor.pickState()); // TUI2-R2 ②: the resume picker's band
|
|
202
211
|
return editorInput(editor);
|
|
203
212
|
}
|
|
204
213
|
return readlineInput(createInterface({ input: process.stdin, output: process.stdout }));
|
|
@@ -356,6 +365,9 @@ async function makeAgent(sessionId, input, modelFlag) {
|
|
|
356
365
|
if (isFirstRun())
|
|
357
366
|
scaffoldFirstRun();
|
|
358
367
|
const store = new SessionStore(sessionsDir());
|
|
368
|
+
// TUI2-R2 ②/③: the navigation surfaces read through THIS store — one
|
|
369
|
+
// store per process, and the picker/listing never write through it.
|
|
370
|
+
setSessionStore(store);
|
|
359
371
|
// E area: the durable script position — computed AFTER the verdict
|
|
360
372
|
// (fauxSkip's session-log read is a home read: pre-trust zero-read).
|
|
361
373
|
const fauxSkipTurns = sessionId === undefined ? 0 : fauxSkip(sessionId);
|
|
@@ -441,6 +453,74 @@ async function makeAgent(sessionId, input, modelFlag) {
|
|
|
441
453
|
};
|
|
442
454
|
return createAgent(definition);
|
|
443
455
|
}
|
|
456
|
+
/** TUI2-R2 ② — the picker's affordance row: the keys, said where the
|
|
457
|
+
* keys are useful. */
|
|
458
|
+
const PICKER_HINT = "↑↓ pick · ⏎ resumes · type filters · esc";
|
|
459
|
+
/**
|
|
460
|
+
* TUI2-R2 ①–③ — the listing's cards. The projection consumes the
|
|
461
|
+
* runtime's own accessors (see session-cards.ts); this is only the
|
|
462
|
+
* plumbing that hands it the store's read side.
|
|
463
|
+
*/
|
|
464
|
+
async function sessionCards(agent) {
|
|
465
|
+
const store = sessionStoreRef;
|
|
466
|
+
if (store === null)
|
|
467
|
+
return []; // unreachable: makeAgent builds the store first
|
|
468
|
+
return collectSessionCards(agent, (id) => store.load(id));
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* TUI2-R2 ② — the resume picker: the band, the keys, the id.
|
|
472
|
+
*
|
|
473
|
+
* The status row carries the picker's own affordance while it is up
|
|
474
|
+
* (a surface teaches its keys where the keys are useful), and the
|
|
475
|
+
* promise settles on the editor's commit — the id the human took, or
|
|
476
|
+
* null when they left. The picker path WRITES NOTHING: the cards are a
|
|
477
|
+
* projection over what is already on disk.
|
|
478
|
+
*/
|
|
479
|
+
async function pickSession(agent, input) {
|
|
480
|
+
const cards = await sessionCards(agent);
|
|
481
|
+
if (cards.length === 0) {
|
|
482
|
+
bodyLog("no sessions yet \u2014 `kiso` starts one");
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
// a dock-less TTY (rows < 4) has no band to draw the picker in, so the
|
|
486
|
+
// honest answer is the usage line this command has always printed.
|
|
487
|
+
if (input.pick === undefined || !dock.active) {
|
|
488
|
+
console.error('usage: kiso resume <sessionId> ["prompt"]');
|
|
489
|
+
process.exit(2);
|
|
490
|
+
}
|
|
491
|
+
dock.setStatus("", PICKER_HINT);
|
|
492
|
+
const picked = await new Promise((resolve) => {
|
|
493
|
+
input.pick(() => cards, resolve);
|
|
494
|
+
});
|
|
495
|
+
dock.setStatus("", null);
|
|
496
|
+
return picked;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* TUI2-R2 ⑥ — the BOOT status row.
|
|
500
|
+
*
|
|
501
|
+
* The status line is the product's one persistent claim about itself,
|
|
502
|
+
* and it used to appear after turn ONE: the idle-fresh screen — the
|
|
503
|
+
* screen every session opens on, and the only screen a first-time user
|
|
504
|
+
* sees before deciding whether to type — showed an empty row where the
|
|
505
|
+
* tier, the /mode hint, the model and the remaining context belong.
|
|
506
|
+
*
|
|
507
|
+
* It is painted HERE, at the first moment every field is TRUE: after
|
|
508
|
+
* makeAgent, because that is where the model is resolved. Painting it at
|
|
509
|
+
* dock.enter() — the literally-first frame — would have to name a model
|
|
510
|
+
* nobody had chosen yet, and a status row that guesses is worse than a
|
|
511
|
+
* status row that waits two hundred milliseconds.
|
|
512
|
+
*
|
|
513
|
+
* The meter fields (cache rate, cost) are deliberately absent: an
|
|
514
|
+
* unstarted session has made no requests, and an unmeasured cache is not
|
|
515
|
+
* a 0% cache. chat()'s own paintIdle takes over from here with the same
|
|
516
|
+
* formatter — never a boot-time copy, which would drift from the real
|
|
517
|
+
* row the moment either changed.
|
|
518
|
+
*/
|
|
519
|
+
function paintBootStatus(session) {
|
|
520
|
+
if (!dock.active)
|
|
521
|
+
return;
|
|
522
|
+
dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), agentModel, estimateCtxRatio(session)));
|
|
523
|
+
}
|
|
444
524
|
async function main() {
|
|
445
525
|
// E group (the graceful-exit gate ③, R-G 0.1.48): a terminal closing
|
|
446
526
|
// turns the in-flight stdout/stderr writes into EIO, and node's
|
|
@@ -526,11 +606,17 @@ async function main() {
|
|
|
526
606
|
bodyLog(`session ${id}\n`);
|
|
527
607
|
extensionsBanner(recentSessions(id, agent));
|
|
528
608
|
faux = currentFaux;
|
|
609
|
+
paintBootStatus(session); // TUI2-R2 ⑥: the idle-fresh screen says what it is
|
|
529
610
|
await chat(session, faux, input, resolveAutoCompact(mergedConfig));
|
|
530
611
|
break;
|
|
531
612
|
}
|
|
532
613
|
case "resume": {
|
|
533
|
-
|
|
614
|
+
// TUI2-R2 ② — bare `kiso resume` opens the PICKER, but only
|
|
615
|
+
// where there is a human to pick: a pipe keeps today's usage
|
|
616
|
+
// error and today's exit 2, byte for byte. Finding an id used
|
|
617
|
+
// to mean running `kiso sessions` and copying one out by eye;
|
|
618
|
+
// the picker is that step, done by the product.
|
|
619
|
+
if (!arg && !process.stdin.isTTY) {
|
|
534
620
|
console.error("usage: kiso resume <sessionId> [\"prompt\"]");
|
|
535
621
|
process.exit(2);
|
|
536
622
|
}
|
|
@@ -540,8 +626,25 @@ async function main() {
|
|
|
540
626
|
dock.enter();
|
|
541
627
|
agent = await makeAgent(arg, input, modelFlag);
|
|
542
628
|
applyConfigMode();
|
|
543
|
-
|
|
629
|
+
let id = arg;
|
|
630
|
+
if (id === undefined) {
|
|
631
|
+
const picked = await pickSession(agent, input);
|
|
632
|
+
// esc: the human looked and chose not to resume. That is a
|
|
633
|
+
// normal outcome, so it exits 0 with nothing said — never
|
|
634
|
+
// an error, never a session started behind their back.
|
|
635
|
+
if (picked === null)
|
|
636
|
+
break;
|
|
637
|
+
id = picked;
|
|
638
|
+
}
|
|
639
|
+
const session = await agent.session({ id });
|
|
544
640
|
faux = currentFaux;
|
|
641
|
+
// E area: the durable script position is computed from the
|
|
642
|
+
// session id, and on the picker path the id did not exist when
|
|
643
|
+
// makeAgent ran. Re-arm the scripted adapter at the PICKED
|
|
644
|
+
// session's position so a picked resume continues its script
|
|
645
|
+
// exactly where `kiso resume <id>` would have.
|
|
646
|
+
if (faux && arg === undefined)
|
|
647
|
+
session.setAdapter(createFauxProvider(readFauxScript().slice(fauxSkip(id))));
|
|
545
648
|
await resume(session, prompt, faux, input);
|
|
546
649
|
break;
|
|
547
650
|
}
|
|
@@ -555,8 +658,23 @@ async function main() {
|
|
|
555
658
|
// exists so the gate's ask can be answered; the listing
|
|
556
659
|
// itself never touches it.
|
|
557
660
|
agent = await makeAgent(undefined, input, modelFlag);
|
|
558
|
-
|
|
559
|
-
|
|
661
|
+
// TUI2-R2 ③ — the same projection the picker renders, printed.
|
|
662
|
+
// The PIPE keeps today's bytes exactly: `kiso sessions` is
|
|
663
|
+
// something scripts read, and a badge column is a TTY-render
|
|
664
|
+
// concern, not a change to a machine interface.
|
|
665
|
+
if (process.stdout.isTTY) {
|
|
666
|
+
const cards = await sessionCards(agent);
|
|
667
|
+
const W = process.stdout.columns ?? 80;
|
|
668
|
+
const col = idColumn(cards);
|
|
669
|
+
const now = Date.now();
|
|
670
|
+
for (const card of cards)
|
|
671
|
+
console.log(sessionListRow(card, W, now, col));
|
|
672
|
+
console.log(sessionListFooter(cards.length, W));
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
for (const meta of agent.sessions()) {
|
|
676
|
+
console.log(renderSessionLine(meta));
|
|
677
|
+
}
|
|
560
678
|
}
|
|
561
679
|
break;
|
|
562
680
|
}
|
|
@@ -593,6 +711,7 @@ async function main() {
|
|
|
593
711
|
// session surfaced as "[faux mode] the scripted model failed:
|
|
594
712
|
// <real error>" (a false accusation of the keyless demo).
|
|
595
713
|
faux = currentFaux;
|
|
714
|
+
paintBootStatus(session); // TUI2-R2 ⑥: the same row on the bare command
|
|
596
715
|
await chat(session, faux, input, autoCompactFromEnv());
|
|
597
716
|
break;
|
|
598
717
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI2-R2 slice ① — the state projection: a session's durable log →
|
|
3
|
+
* the card the picker and the listing both render.
|
|
4
|
+
*
|
|
5
|
+
* The card is a PURE PROJECTION and this file adds no facts. Every field
|
|
6
|
+
* comes from a durable event the product already writes, read through the
|
|
7
|
+
* runtime's OWN accessors:
|
|
8
|
+
*
|
|
9
|
+
* openRunId(records) — recovery's own "is a run still open?"
|
|
10
|
+
* (the ▌ brick: no terminal event)
|
|
11
|
+
* executionLedger(events) — the L2 ledger's own uncertain status
|
|
12
|
+
* (started, no receipt — the crash window)
|
|
13
|
+
* pendingApprovals() — the session's own live-ask accessor
|
|
14
|
+
* (decided/expired/dead-run all excluded)
|
|
15
|
+
* the `terminal` event — its outcome kind, read as written
|
|
16
|
+
*
|
|
17
|
+
* Nothing here re-implements any of them. That is the point: a badge is a
|
|
18
|
+
* claim about what `kiso resume` will do, and the only way it can never
|
|
19
|
+
* lie is for it to be derived from the same facts the resume derives
|
|
20
|
+
* from. A second derivation — however careful — is a second source of
|
|
21
|
+
* truth, and two sources of truth about durability is none.
|
|
22
|
+
*
|
|
23
|
+
* The projection is read-only by construction: it loads and it counts.
|
|
24
|
+
* The R-I-p2 rule (a listing never writes) holds trivially — there is no
|
|
25
|
+
* write call in this file, and the accessors it calls have none either.
|
|
26
|
+
*
|
|
27
|
+
* What a human READS about a card is presentation and lives in the
|
|
28
|
+
* terminal layer (sessionNote — the KC3 §1 split); this file owns the
|
|
29
|
+
* FACTS.
|
|
30
|
+
*/
|
|
31
|
+
import { type StoreRecord } from "@vincemakes/kiso-runtime/internal";
|
|
32
|
+
/** The five durable states, in the order the projection resolves them. */
|
|
33
|
+
export type SessionBadge = "uncertain" | "ask" | "interrupted" | "completed" | "failed";
|
|
34
|
+
export interface SessionCard {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly badge: SessionBadge;
|
|
37
|
+
/** The human's unit: one user_input is one turn. (SessionMeta.runs
|
|
38
|
+
* counts runIds, which a resume increments without the human having
|
|
39
|
+
* said anything — that is a different number and not the one a
|
|
40
|
+
* picker row means.) */
|
|
41
|
+
readonly turns: number;
|
|
42
|
+
/** The last record's own stamp — the store's `updatedAt`, never a
|
|
43
|
+
* file mtime (a copied home would lie about every age). */
|
|
44
|
+
readonly updatedAt: number;
|
|
45
|
+
/** The uncertain ledger's size — the ? badge's count. */
|
|
46
|
+
readonly uncertain: number;
|
|
47
|
+
/** The unanswered permission requests — the ◌ badge's count. */
|
|
48
|
+
readonly asks: number;
|
|
49
|
+
/** The terminal outcome's kind, when the run ended; null while it is
|
|
50
|
+
* open. The ✗ note names it rather than inventing one word for six
|
|
51
|
+
* different endings. */
|
|
52
|
+
readonly outcome: string | null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The projection. `asks` arrives as DATA because its accessor lives on
|
|
56
|
+
* the session object (pendingApprovals) rather than on the record list —
|
|
57
|
+
* passing the count keeps this function pure and unit-testable over a
|
|
58
|
+
* fixture the real writer produced.
|
|
59
|
+
*
|
|
60
|
+
* The precedence, and why it is this one:
|
|
61
|
+
*
|
|
62
|
+
* 1. uncertain — the spec's explicit override. An interrupted run that
|
|
63
|
+
* also holds an undecided side effect cannot resume until a human
|
|
64
|
+
* rules on it, so the badge must show the blocking condition, not
|
|
65
|
+
* the recoverable one.
|
|
66
|
+
* 2. ask — an unanswered question is likewise the thing standing
|
|
67
|
+
* between the session and its own continuation.
|
|
68
|
+
* 3. interrupted — no terminal event: the run was cut mid-flight.
|
|
69
|
+
* 4/5. the terminal's own verdict.
|
|
70
|
+
*/
|
|
71
|
+
export declare function projectSessionCard(input: {
|
|
72
|
+
readonly id: string;
|
|
73
|
+
readonly updatedAt: number;
|
|
74
|
+
readonly records: readonly StoreRecord[];
|
|
75
|
+
readonly asks: number;
|
|
76
|
+
}): SessionCard;
|
|
77
|
+
/** The shapes this module needs from the agent — structural, so the
|
|
78
|
+
* projection is testable without standing up a provider. */
|
|
79
|
+
interface CardSource {
|
|
80
|
+
sessions(): readonly {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
readonly updatedAt: number;
|
|
83
|
+
}[];
|
|
84
|
+
session(options: {
|
|
85
|
+
id: string;
|
|
86
|
+
}): Promise<{
|
|
87
|
+
pendingApprovals(): readonly unknown[];
|
|
88
|
+
}>;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The listing's cards, newest first — the order both the picker and
|
|
92
|
+
* `kiso sessions` want (the thing you just left is the thing you are
|
|
93
|
+
* most likely coming back for).
|
|
94
|
+
*
|
|
95
|
+
* The session object exists ONLY to read pendingApprovals(): opening one
|
|
96
|
+
* loads its records and builds an event log, and writes nothing (no
|
|
97
|
+
* lock, no file creation) — the read-only listing rule holds.
|
|
98
|
+
*/
|
|
99
|
+
export declare function collectSessionCards(agent: CardSource, load: (id: string) => readonly StoreRecord[]): Promise<SessionCard[]>;
|
|
100
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI2-R2 slice ① — the state projection: a session's durable log →
|
|
3
|
+
* the card the picker and the listing both render.
|
|
4
|
+
*
|
|
5
|
+
* The card is a PURE PROJECTION and this file adds no facts. Every field
|
|
6
|
+
* comes from a durable event the product already writes, read through the
|
|
7
|
+
* runtime's OWN accessors:
|
|
8
|
+
*
|
|
9
|
+
* openRunId(records) — recovery's own "is a run still open?"
|
|
10
|
+
* (the ▌ brick: no terminal event)
|
|
11
|
+
* executionLedger(events) — the L2 ledger's own uncertain status
|
|
12
|
+
* (started, no receipt — the crash window)
|
|
13
|
+
* pendingApprovals() — the session's own live-ask accessor
|
|
14
|
+
* (decided/expired/dead-run all excluded)
|
|
15
|
+
* the `terminal` event — its outcome kind, read as written
|
|
16
|
+
*
|
|
17
|
+
* Nothing here re-implements any of them. That is the point: a badge is a
|
|
18
|
+
* claim about what `kiso resume` will do, and the only way it can never
|
|
19
|
+
* lie is for it to be derived from the same facts the resume derives
|
|
20
|
+
* from. A second derivation — however careful — is a second source of
|
|
21
|
+
* truth, and two sources of truth about durability is none.
|
|
22
|
+
*
|
|
23
|
+
* The projection is read-only by construction: it loads and it counts.
|
|
24
|
+
* The R-I-p2 rule (a listing never writes) holds trivially — there is no
|
|
25
|
+
* write call in this file, and the accessors it calls have none either.
|
|
26
|
+
*
|
|
27
|
+
* What a human READS about a card is presentation and lives in the
|
|
28
|
+
* terminal layer (sessionNote — the KC3 §1 split); this file owns the
|
|
29
|
+
* FACTS.
|
|
30
|
+
*/
|
|
31
|
+
import { executionLedger, openRunId } from "@vincemakes/kiso-runtime/internal";
|
|
32
|
+
/**
|
|
33
|
+
* The projection. `asks` arrives as DATA because its accessor lives on
|
|
34
|
+
* the session object (pendingApprovals) rather than on the record list —
|
|
35
|
+
* passing the count keeps this function pure and unit-testable over a
|
|
36
|
+
* fixture the real writer produced.
|
|
37
|
+
*
|
|
38
|
+
* The precedence, and why it is this one:
|
|
39
|
+
*
|
|
40
|
+
* 1. uncertain — the spec's explicit override. An interrupted run that
|
|
41
|
+
* also holds an undecided side effect cannot resume until a human
|
|
42
|
+
* rules on it, so the badge must show the blocking condition, not
|
|
43
|
+
* the recoverable one.
|
|
44
|
+
* 2. ask — an unanswered question is likewise the thing standing
|
|
45
|
+
* between the session and its own continuation.
|
|
46
|
+
* 3. interrupted — no terminal event: the run was cut mid-flight.
|
|
47
|
+
* 4/5. the terminal's own verdict.
|
|
48
|
+
*/
|
|
49
|
+
export function projectSessionCard(input) {
|
|
50
|
+
const events = input.records.map((r) => r.event);
|
|
51
|
+
// the ledger's own expression — the same one the recovery plan and the
|
|
52
|
+
// session's uncertainExecutions() use, so the three can never disagree
|
|
53
|
+
const uncertain = [...executionLedger(events).values()].filter((r) => r.status === "uncertain").length;
|
|
54
|
+
const open = openRunId(input.records) !== undefined;
|
|
55
|
+
// the LAST terminal is the session's verdict — an older run's ending
|
|
56
|
+
// says nothing about where the session stands now
|
|
57
|
+
let outcome = null;
|
|
58
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
59
|
+
const ev = events[i];
|
|
60
|
+
if (ev.type === "terminal") {
|
|
61
|
+
outcome = ev.outcome.kind;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const turns = events.filter((e) => e.type === "user_input").length;
|
|
66
|
+
const badge = uncertain > 0
|
|
67
|
+
? "uncertain"
|
|
68
|
+
: input.asks > 0
|
|
69
|
+
? "ask"
|
|
70
|
+
: // a log with no terminal at all leaves openRunId undefined only
|
|
71
|
+
// when it is empty; either way nothing durable says it ended
|
|
72
|
+
open || outcome === null
|
|
73
|
+
? "interrupted"
|
|
74
|
+
: outcome === "completed"
|
|
75
|
+
? "completed"
|
|
76
|
+
: "failed";
|
|
77
|
+
return { id: input.id, badge, turns, updatedAt: input.updatedAt, uncertain, asks: input.asks, outcome };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The listing's cards, newest first — the order both the picker and
|
|
81
|
+
* `kiso sessions` want (the thing you just left is the thing you are
|
|
82
|
+
* most likely coming back for).
|
|
83
|
+
*
|
|
84
|
+
* The session object exists ONLY to read pendingApprovals(): opening one
|
|
85
|
+
* loads its records and builds an event log, and writes nothing (no
|
|
86
|
+
* lock, no file creation) — the read-only listing rule holds.
|
|
87
|
+
*/
|
|
88
|
+
export async function collectSessionCards(agent, load) {
|
|
89
|
+
const cards = [];
|
|
90
|
+
for (const meta of [...agent.sessions()].sort((a, b) => b.updatedAt - a.updatedAt)) {
|
|
91
|
+
const session = await agent.session({ id: meta.id });
|
|
92
|
+
cards.push(projectSessionCard({ id: meta.id, updatedAt: meta.updatedAt, records: load(meta.id), asks: session.pendingApprovals().length }));
|
|
93
|
+
}
|
|
94
|
+
return cards;
|
|
95
|
+
}
|
package/dist/state.d.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* creates the mutable ones (setBody / setAgentModel / setExtensionLists);
|
|
6
6
|
* the moved modules read and mutate at call time.
|
|
7
7
|
*/
|
|
8
|
-
import { Dock, type AtItem, type Body, type PanelVerdict, type PanelView } from "@vincemakes/kiso-tui";
|
|
9
|
-
import type { KisoExtension } from "@vincemakes/kiso-runtime";
|
|
8
|
+
import { Dock, type AtItem, type Body, type PanelVerdict, type PanelView, type SessionCardView } from "@vincemakes/kiso-tui";
|
|
9
|
+
import type { KisoExtension, StoreRecord } from "@vincemakes/kiso-runtime";
|
|
10
10
|
/** finding #11: KISO_HOME is the ONE root — every default path derives from
|
|
11
11
|
* it (sessions, trust, extensions, mcp config, skills). The dedicated
|
|
12
12
|
* env vars (KISO_EXTENSIONS_DIR / KISO_MCP_CONFIG / KISO_SKILLS_DIR)
|
|
@@ -15,6 +15,22 @@ export declare function kisoHome(): string;
|
|
|
15
15
|
export declare function sessionsDir(): string;
|
|
16
16
|
/** E1: the extension scan directory — KISO_EXTENSIONS_DIR overrides. */
|
|
17
17
|
export declare function extensionsDir(): string;
|
|
18
|
+
/**
|
|
19
|
+
* TUI2-R1 (E) — the /context ledger, read from the session's TRACE
|
|
20
|
+
* SIDECAR (<sessions>/traces/<id>.jsonl).
|
|
21
|
+
*
|
|
22
|
+
* THE PURITY GATE (ADR-0051 §6, ruling R7) IS UNTOUCHED. The trace
|
|
23
|
+
* surface is an observation surface; correctness never reads it, and
|
|
24
|
+
* this reader is on the DISPLAY path only — nothing it returns reaches a
|
|
25
|
+
* recovery plan, a projection or a request. The proof of that is the
|
|
26
|
+
* shape of this function: it is best-effort end to end, and its failure
|
|
27
|
+
* mode is `null`, which renders a sentence rather than a number.
|
|
28
|
+
*
|
|
29
|
+
* The LAST request line is the one that matters: rent is per request,
|
|
30
|
+
* and what the reader wants to know is what the NEXT request will cost,
|
|
31
|
+
* which is what the previous one cost.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readContextLedger(sessionId: string, window: number): import("@vincemakes/kiso-tui").ContextLedger | null;
|
|
18
34
|
/**
|
|
19
35
|
* KC3 §5 — the @ picker's file source. Computed PER OPEN: no index, no
|
|
20
36
|
* daemon, no watcher, nothing to invalidate and nothing to go stale.
|
|
@@ -67,6 +83,12 @@ export interface LineInput {
|
|
|
67
83
|
panelAsk(view: PanelView, onCommit: (v: PanelVerdict) => void): void;
|
|
68
84
|
/** W21: cancel the panel — the SIGINT pair to panelAsk. */
|
|
69
85
|
panelCancel(): void;
|
|
86
|
+
/** TUI2-R2 ②: open the session picker — the editor takes the keys
|
|
87
|
+
* (the selection walk, the filter, enter/esc) and hands back the
|
|
88
|
+
* picked id, or null when the human leaves without picking.
|
|
89
|
+
* OPTIONAL: only the raw-mode editor has it, and the picker only
|
|
90
|
+
* ever opens on a TTY (a pipe has nobody to pick). */
|
|
91
|
+
pick?(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void): void;
|
|
70
92
|
/** W22: bind the pending-turn queue — the ↑ pop walks the CLI's
|
|
71
93
|
* live slots (each pop cancels the turn), esc ends the walk after
|
|
72
94
|
* one more pop. The chips are the compositor's own bindQueue. */
|
|
@@ -90,7 +112,17 @@ export declare let body: Body;
|
|
|
90
112
|
export declare function setBody(value: Body): void;
|
|
91
113
|
/** v2d: body output routes through the cell renderer — the single writer.
|
|
92
114
|
* bodyLog adds the trailing newline; internal newlines are preserved. */
|
|
93
|
-
export declare function bodyLog(text: string): void;
|
|
115
|
+
export declare function bodyLog(text: string, wrap?: "words"): void;
|
|
116
|
+
/** TUI2-R2 ②/③: the session store makeAgent built — the ONE store per
|
|
117
|
+
* process. The navigation surfaces need its read side (load) to project
|
|
118
|
+
* the badges, and a second store on the same root would be a second
|
|
119
|
+
* lock manager for a job that never writes. */
|
|
120
|
+
export declare let sessionStoreRef: {
|
|
121
|
+
load(id: string): readonly StoreRecord[];
|
|
122
|
+
} | null;
|
|
123
|
+
export declare function setSessionStore(value: {
|
|
124
|
+
load(id: string): readonly StoreRecord[];
|
|
125
|
+
}): void;
|
|
94
126
|
/** The model name for the status bar — set by makeAgent. */
|
|
95
127
|
export declare let agentModel: string;
|
|
96
128
|
export declare function setAgentModel(value: string): void;
|
package/dist/state.js
CHANGED
|
@@ -25,6 +25,71 @@ export function sessionsDir() {
|
|
|
25
25
|
export function extensionsDir() {
|
|
26
26
|
return process.env.KISO_EXTENSIONS_DIR ?? join(kisoHome(), "extensions");
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* TUI2-R1 (E) — the /context ledger, read from the session's TRACE
|
|
30
|
+
* SIDECAR (<sessions>/traces/<id>.jsonl).
|
|
31
|
+
*
|
|
32
|
+
* THE PURITY GATE (ADR-0051 §6, ruling R7) IS UNTOUCHED. The trace
|
|
33
|
+
* surface is an observation surface; correctness never reads it, and
|
|
34
|
+
* this reader is on the DISPLAY path only — nothing it returns reaches a
|
|
35
|
+
* recovery plan, a projection or a request. The proof of that is the
|
|
36
|
+
* shape of this function: it is best-effort end to end, and its failure
|
|
37
|
+
* mode is `null`, which renders a sentence rather than a number.
|
|
38
|
+
*
|
|
39
|
+
* The LAST request line is the one that matters: rent is per request,
|
|
40
|
+
* and what the reader wants to know is what the NEXT request will cost,
|
|
41
|
+
* which is what the previous one cost.
|
|
42
|
+
*/
|
|
43
|
+
export function readContextLedger(sessionId, window) {
|
|
44
|
+
let last = null;
|
|
45
|
+
try {
|
|
46
|
+
const text = readFileSync(join(sessionsDir(), "traces", `${sessionId}.jsonl`), "utf8");
|
|
47
|
+
for (const line of text.split("\n")) {
|
|
48
|
+
if (line === "")
|
|
49
|
+
continue;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(line);
|
|
52
|
+
if (parsed.kind === "request")
|
|
53
|
+
last = parsed;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// a torn last line (the writer was mid-append) — the ledger is
|
|
57
|
+
// an observation, and a partial one is simply not the answer
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null; // no sidecar — a session that has not called the model
|
|
63
|
+
}
|
|
64
|
+
if (last === null)
|
|
65
|
+
return null;
|
|
66
|
+
const rent = Array.isArray(last.rent) ? last.rent : [];
|
|
67
|
+
if (rent.length === 0)
|
|
68
|
+
return null; // a v1/v2 sidecar carries no rent block (R2-1)
|
|
69
|
+
const sum = (pred) => rent.filter((l) => typeof l.surface === "string" && pred(l.surface)).reduce((a, l) => a + (l.estTokens ?? 0), 0);
|
|
70
|
+
const count = (pred) => rent.filter((l) => typeof l.surface === "string" && pred(l.surface)).length;
|
|
71
|
+
// the skills index is broken out: it is an INDEX of workspace content,
|
|
72
|
+
// not an instruction, and it is the one append whose size is the
|
|
73
|
+
// reader's own doing.
|
|
74
|
+
const isSkills = (s) => s === "system:ext:skills";
|
|
75
|
+
const manifest = Array.isArray(last.contextManifest) ? last.contextManifest : [];
|
|
76
|
+
const turnSegments = manifest.filter((s) => s.role === "turn" || s.role === "current_turn");
|
|
77
|
+
return {
|
|
78
|
+
window,
|
|
79
|
+
systemPrompt: sum((s) => s === "system:base" || (s.startsWith("system:ext:") && !isSkills(s))),
|
|
80
|
+
systemBase: sum((s) => s === "system:base"),
|
|
81
|
+
appends: count((s) => s.startsWith("system:ext:") && !isSkills(s)),
|
|
82
|
+
toolTable: sum((s) => s.startsWith("tool:")),
|
|
83
|
+
tools: count((s) => s.startsWith("tool:")),
|
|
84
|
+
skillsIndex: sum(isSkills),
|
|
85
|
+
// the ledger records the SURFACE, never its contents — the number
|
|
86
|
+
// of skills is not in it, and 0 tells the renderer to say so.
|
|
87
|
+
skills: 0,
|
|
88
|
+
envelope: sum((s) => s === "envelope"),
|
|
89
|
+
messages: turnSegments.reduce((a, s) => a + (s.estTokens ?? 0), 0),
|
|
90
|
+
turns: turnSegments.length,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
28
93
|
/**
|
|
29
94
|
* KC3 §5 — the @ picker's file source. Computed PER OPEN: no index, no
|
|
30
95
|
* daemon, no watcher, nothing to invalidate and nothing to go stale.
|
|
@@ -95,8 +160,16 @@ export function setBody(value) {
|
|
|
95
160
|
}
|
|
96
161
|
/** v2d: body output routes through the cell renderer — the single writer.
|
|
97
162
|
* bodyLog adds the trailing newline; internal newlines are preserved. */
|
|
98
|
-
export function bodyLog(text) {
|
|
99
|
-
body.raw(text.split("\n"));
|
|
163
|
+
export function bodyLog(text, wrap) {
|
|
164
|
+
body.raw(text.split("\n"), wrap);
|
|
165
|
+
}
|
|
166
|
+
/** TUI2-R2 ②/③: the session store makeAgent built — the ONE store per
|
|
167
|
+
* process. The navigation surfaces need its read side (load) to project
|
|
168
|
+
* the badges, and a second store on the same root would be a second
|
|
169
|
+
* lock manager for a job that never writes. */
|
|
170
|
+
export let sessionStoreRef = null;
|
|
171
|
+
export function setSessionStore(value) {
|
|
172
|
+
sessionStoreRef = value;
|
|
100
173
|
}
|
|
101
174
|
/** The model name for the status bar — set by makeAgent. */
|
|
102
175
|
export let agentModel = "faux";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "kiso CLI — the durable coding agent that survives kill -9: kiso chat / kiso resume / kiso sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,19 +18,19 @@
|
|
|
18
18
|
"test": "vitest run"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@vincemakes/kiso-ask-ext": "0.
|
|
22
|
-
"@vincemakes/kiso-core": "0.
|
|
23
|
-
"@vincemakes/kiso-evals": "0.
|
|
24
|
-
"@vincemakes/kiso-mcp-ext": "0.
|
|
25
|
-
"@vincemakes/kiso-provider-anthropic": "0.
|
|
26
|
-
"@vincemakes/kiso-provider-openai": "0.
|
|
27
|
-
"@vincemakes/kiso-runtime": "0.
|
|
28
|
-
"@vincemakes/kiso-skills-ext": "0.
|
|
29
|
-
"@vincemakes/kiso-subagent-ext": "0.
|
|
30
|
-
"@vincemakes/kiso-task-ext": "0.
|
|
31
|
-
"@vincemakes/kiso-tools-node": "0.
|
|
32
|
-
"@vincemakes/kiso-tui": "0.
|
|
33
|
-
"@vincemakes/kiso-tui-cells": "0.
|
|
21
|
+
"@vincemakes/kiso-ask-ext": "0.10.0",
|
|
22
|
+
"@vincemakes/kiso-core": "0.10.0",
|
|
23
|
+
"@vincemakes/kiso-evals": "0.10.0",
|
|
24
|
+
"@vincemakes/kiso-mcp-ext": "0.10.0",
|
|
25
|
+
"@vincemakes/kiso-provider-anthropic": "0.10.0",
|
|
26
|
+
"@vincemakes/kiso-provider-openai": "0.10.0",
|
|
27
|
+
"@vincemakes/kiso-runtime": "0.10.0",
|
|
28
|
+
"@vincemakes/kiso-skills-ext": "0.10.0",
|
|
29
|
+
"@vincemakes/kiso-subagent-ext": "0.10.0",
|
|
30
|
+
"@vincemakes/kiso-task-ext": "0.10.0",
|
|
31
|
+
"@vincemakes/kiso-tools-node": "0.10.0",
|
|
32
|
+
"@vincemakes/kiso-tui": "0.10.0",
|
|
33
|
+
"@vincemakes/kiso-tui-cells": "0.10.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "^26.1.2",
|