@vincemakes/kiso-code 0.1.20 → 0.1.22

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 ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 手感批 B4 (pure move) — the interactive REPL (chat), the run consumer
3
+ * (consumeRun — the single renderer of a run's event stream), the
4
+ * approval-moment mini-diff, the status spinner, and the context
5
+ * estimates. All bodies moved verbatim from index.ts.
6
+ */
7
+ import { type RunUsage } from "@vincemakes/kiso-tui";
8
+ import type { AgentSession } from "@vincemakes/kiso-runtime";
9
+ import { type LineInput } from "./state.js";
10
+ /**
11
+ * 手感批 C8 — the /compact auto-trigger, OPT-IN (default off: only an
12
+ * explicit KISO_AUTO_COMPACT=<ratio> enables it — the CLI never defaults
13
+ * it on). After every completed turn the ~ctx ratio is checked; at/over
14
+ * thresholdRatio the /compact full path runs (the same dispatch — same
15
+ * notices, same chain ordering, same mid-run refusal).
16
+ */
17
+ export interface AutoCompact {
18
+ /** 0 < r < 1 — the ~ctx ratio that triggers the compaction. */
19
+ readonly thresholdRatio: number;
20
+ }
21
+ /** Parse KISO_AUTO_COMPACT — an invalid value is OFF, never a crash. */
22
+ export declare function autoCompactFromEnv(): AutoCompact | undefined;
23
+ /**
24
+ * C 区: the model window in tokens — KISO_CONTEXT_WINDOW overrides the
25
+ * 200k default. The microcompact threshold is derived from it (50%), and
26
+ * the status line's ~ctx estimate is measured against it — one source of
27
+ * truth for the window.
28
+ */
29
+ export declare function contextWindowTokens(): number;
30
+ /**
31
+ * B 区: approximate context ratio — chars/4 of the projected messages vs
32
+ * the model window. Marked ~ everywhere it is shown; no counting API.
33
+ */
34
+ export declare function estimateCtxRatio(session: AgentSession): number;
35
+ /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
36
+ * is gone) — docked only, 200ms rotation between the request and the
37
+ * first event. */
38
+ export declare function startStatusSpinner(onTick: (glyph: string) => void): () => void;
39
+ /**
40
+ * Consume a run, answering approval pauses as they arrive. `resumeMode`
41
+ * marks a session.resume() continuation. v2a: `faux` picks the status
42
+ * line's form; `liveInput` (non-null only in interactive chat) carries the
43
+ * last line THIS process's readline consumed — the double-echo filter.
44
+ */
45
+ export declare function consumeRun(session: AgentSession, run: AsyncIterable<import("@vincemakes/kiso-core").Event>, input: LineInput, turnNo: number, faux: boolean, liveInput: {
46
+ current: string | null;
47
+ } | null, statusCb: ((usage: RunUsage, ctxRatio: number) => void) | null): Promise<import("@vincemakes/kiso-core").Event | undefined>;
48
+ /** Interactive REPL: stream events, pause for approvals, Ctrl+C aborts. */
49
+ export declare function chat(session: AgentSession, faux: boolean, input: LineInput, autoCompact?: AutoCompact): Promise<void>;
package/dist/chat.js ADDED
@@ -0,0 +1,483 @@
1
+ /**
2
+ * 手感批 B4 (pure move) — the interactive REPL (chat), the run consumer
3
+ * (consumeRun — the single renderer of a run's event stream), the
4
+ * approval-moment mini-diff, the status spinner, and the context
5
+ * estimates. All bodies moved verbatim from index.ts.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { escapeTerminal, kUnit, palette, renderEvent, renderRecap } from "@vincemakes/kiso-tui";
9
+ import { editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
+ import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
11
+ import { dispatch } from "./dispatch.js";
12
+ import { CANCELLED, agentModel, body, bodyLog, dock } from "./state.js";
13
+ import { ask, pendingAsk, resolveUncertains } from "./trust-ui.js";
14
+ import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
15
+ import { MODES, getMode, setMode } from "./mode.js";
16
+ /** B 区: default context window for the ~ctx estimate (config overridable). */
17
+ const DEFAULT_CONTEXT_WINDOW = 200_000;
18
+ /** Parse KISO_AUTO_COMPACT — an invalid value is OFF, never a crash. */
19
+ export function autoCompactFromEnv() {
20
+ const raw = process.env.KISO_AUTO_COMPACT;
21
+ if (raw === undefined)
22
+ return undefined;
23
+ const ratio = Number.parseFloat(raw);
24
+ if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1)
25
+ return undefined;
26
+ return { thresholdRatio: ratio };
27
+ }
28
+ /**
29
+ * C 区: the model window in tokens — KISO_CONTEXT_WINDOW overrides the
30
+ * 200k default. The microcompact threshold is derived from it (50%), and
31
+ * the status line's ~ctx estimate is measured against it — one source of
32
+ * truth for the window.
33
+ */
34
+ export function contextWindowTokens() {
35
+ const window = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
36
+ return Number.isFinite(window) && window > 0 ? window : DEFAULT_CONTEXT_WINDOW;
37
+ }
38
+ /**
39
+ * B 区: approximate context ratio — chars/4 of the projected messages vs
40
+ * the model window. Marked ~ everywhere it is shown; no counting API.
41
+ */
42
+ export function estimateCtxRatio(session) {
43
+ const projected = session.projected();
44
+ const chars = JSON.stringify(projected).length;
45
+ return chars / 4 / contextWindowTokens();
46
+ }
47
+ /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
48
+ * is gone) — docked only, 200ms rotation between the request and the
49
+ * first event. */
50
+ export function startStatusSpinner(onTick) {
51
+ if (!dock.active)
52
+ return () => { };
53
+ // v3 §03/§05: the working glyph family ▖▘▝▗, 200ms rotation — the
54
+ // callback repaints the running status line with the new glyph.
55
+ const GLYPHS = ["▖", "▘", "▝", "▗"];
56
+ let i = 0;
57
+ const timer = setInterval(() => onTick(GLYPHS[i++ % GLYPHS.length]), 200);
58
+ timer.unref();
59
+ return () => clearInterval(timer);
60
+ }
61
+ /** v2e: the approval-moment mini-diff — edit_file/write_file changes as
62
+ * ± lines; other tools get null (no diff, no cost). The file read is
63
+ * best-effort: an unreadable file yields NO diff, never a failure —
64
+ * the diff must never break the approval. */
65
+ function approvalDiff(name, input) {
66
+ if (name !== "edit_file" && name !== "write_file")
67
+ return null;
68
+ const path = typeof input.path === "string" ? input.path : "";
69
+ if (path === "")
70
+ return null;
71
+ let oldContent = null;
72
+ try {
73
+ oldContent = readFileSync(path, "utf8");
74
+ }
75
+ catch {
76
+ // a new write_file target (or an unreadable one) — all + degrades
77
+ }
78
+ try {
79
+ if (name === "edit_file") {
80
+ const search = typeof input.search === "string" ? input.search : "";
81
+ const replace = typeof input.replace === "string" ? input.replace : "";
82
+ if (search === "")
83
+ return null;
84
+ return editFileDiff(oldContent ?? "", search, replace);
85
+ }
86
+ const content = typeof input.content === "string" ? input.content : "";
87
+ return writeFileDiff(oldContent, content);
88
+ }
89
+ catch {
90
+ return null; // never let the diff break the approval
91
+ }
92
+ }
93
+ /**
94
+ * 手感批 C5 — the translation layer: the tui renders its OWN data shape
95
+ * (RenderInput, zero kiso-core imports); the CLI translates its Event
96
+ * stream here. Events without a render (stop, expired, resolved, …) → null,
97
+ * and the consumer skips them — the pipe bytes stay identical.
98
+ */
99
+ function toRenderInput(ev) {
100
+ switch (ev.type) {
101
+ case "user_input":
102
+ return { type: "user_input", content: ev.content };
103
+ case "text_delta":
104
+ return { type: "text_delta", text: ev.text };
105
+ case "text_end":
106
+ return { type: "text_end" };
107
+ case "thinking":
108
+ return { type: "thinking", text: ev.text };
109
+ case "tool_call_end":
110
+ return { type: "tool_call_end", name: ev.name, input: ev.input };
111
+ case "tool_execution_started":
112
+ return { type: "tool_execution_started" };
113
+ case "tool_execution_succeeded":
114
+ return { type: "tool_execution_succeeded" };
115
+ case "tool_execution_failed":
116
+ return { type: "tool_execution_failed", error: ev.error };
117
+ case "tool_result":
118
+ return { type: "tool_result", content: ev.content, isError: ev.isError };
119
+ case "permission_requested":
120
+ return { type: "permission_requested", name: ev.name, input: ev.input };
121
+ case "permission_decided":
122
+ return { type: "permission_decided", decision: ev.decision, ...(ev.reason !== undefined ? { reason: ev.reason } : {}) };
123
+ case "terminal":
124
+ return { type: "terminal", outcome: ev.outcome };
125
+ case "compacted":
126
+ return { type: "compacted", cleared: ev.cleared };
127
+ case "summarized":
128
+ return { type: "summarized", coversToSeq: ev.coversToSeq };
129
+ case "uncertain_pending":
130
+ return { type: "uncertain_pending", name: ev.name, executionId: ev.executionId, error: ev.error };
131
+ default:
132
+ return null; // events without a render (stop, expired, resolved, …)
133
+ }
134
+ }
135
+ /**
136
+ * Consume a run, answering approval pauses as they arrive. `resumeMode`
137
+ * marks a session.resume() continuation. v2a: `faux` picks the status
138
+ * line's form; `liveInput` (non-null only in interactive chat) carries the
139
+ * last line THIS process's readline consumed — the double-echo filter.
140
+ */
141
+ export async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
142
+ let last;
143
+ let usage = { in: null, out: null, cache: null, known: false };
144
+ // v3 §02: the recap line derives ENTIRELY from the local event stream
145
+ // (zero tokens) — wall seconds, tool/edit counts, usage, ctx left.
146
+ const turnStart = Date.now();
147
+ let toolCount = 0;
148
+ let editCount = 0;
149
+ try {
150
+ for await (const ev of run) {
151
+ last = ev;
152
+ // v2a (双回显): the interactive echo was already rendered by the
153
+ // input source — rendering the event again is the double echo.
154
+ // v2b: DOCKED — the echo lives in the input row (H), NOT the body;
155
+ // the body render is the ONLY visible copy of the sent line.
156
+ if (ev.type === "user_input" &&
157
+ liveInput !== null &&
158
+ liveInput.current === (typeof ev.content === "string" ? ev.content : "") &&
159
+ process.stdin.isTTY &&
160
+ !dock.active) {
161
+ continue;
162
+ }
163
+ // v2d: EVERY event only mutates a cell — the Body is the single
164
+ // writer of the scroll region, so interleaving is impossible by
165
+ // construction (ADR-0040).
166
+ switch (ev.type) {
167
+ case "user_input":
168
+ body.userLine(typeof ev.content === "string" ? ev.content : "");
169
+ break;
170
+ case "thinking":
171
+ body.thinkingAppend(ev.text);
172
+ break;
173
+ case "tool_call_end":
174
+ toolCount += 1;
175
+ if (ev.name === "edit_file")
176
+ editCount += 1;
177
+ body.toolStart(ev.name, ev.callId, ev.input ?? {});
178
+ break;
179
+ case "tool_execution_started":
180
+ body.toolRunning(ev.callId);
181
+ break;
182
+ case "tool_execution_succeeded":
183
+ body.toolSucceeded(ev.callId);
184
+ break;
185
+ case "tool_execution_failed":
186
+ body.toolFailed(ev.callId, ev.error);
187
+ break;
188
+ case "tool_result": {
189
+ const text = typeof ev.content === "string" ? ev.content : "";
190
+ body.toolResult(ev.callId, { content: text, isError: ev.isError });
191
+ break;
192
+ }
193
+ case "text_delta":
194
+ body.textAppend(ev.text);
195
+ break;
196
+ case "text_end":
197
+ body.textEnd();
198
+ break;
199
+ case "usage":
200
+ usage = { in: ev.inputTokens, out: ev.outputTokens, cache: ev.cacheRead, known: ev.known };
201
+ statusCb?.(usage, estimateCtxRatio(session));
202
+ break;
203
+ case "uncertain_pending":
204
+ // 裁决 #12 (ADR-0038): the ⚠ line is pure INFORMATION now — the
205
+ // approval chain guards retries, and the human question belongs
206
+ // only to the crash window's recovery flow (resolveUncertains).
207
+ body.notice(`⚠ ${escapeTerminal(ev.name)} FAILED — the side effect may have applied. ${escapeTerminal(ev.error)}`);
208
+ break;
209
+ case "permission_requested": {
210
+ // v2d: the ToolCell shows the ⏸ badge; the question takes over
211
+ // the dock status position; the answer lands at the input line.
212
+ // v2e: the mini-diff for edit/write at the approval moment —
213
+ // the human sees the change BEFORE deciding (auto-allowed tools
214
+ // skip the diff: nobody is looking).
215
+ const name = ev.name;
216
+ body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
217
+ const decisionId = ev.decisionId;
218
+ const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
219
+ if (answer === CANCELLED) {
220
+ // 十: a cancellation is a CONSERVATIVE denial, explicitly
221
+ // distinguished from the user typing "n".
222
+ body.notice("[approval cancelled — treated as a denial]");
223
+ await session.approve(decisionId, false);
224
+ continue;
225
+ }
226
+ await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
227
+ break;
228
+ }
229
+ case "terminal": {
230
+ // v3 §02: the run's recap line REPLACES the old "done" label
231
+ // + status line — one local line, derived from this run's
232
+ // events (zero tokens). The dock's status bar still paints.
233
+ statusCb?.(usage, estimateCtxRatio(session));
234
+ const ratio = estimateCtxRatio(session);
235
+ bodyLog(renderRecap({
236
+ seconds: Math.round((Date.now() - turnStart) / 1000),
237
+ tools: toolCount,
238
+ edits: editCount,
239
+ usage,
240
+ ctxLeftPct: Number.isFinite(ratio) ? (1 - ratio) * 100 : null,
241
+ }));
242
+ break;
243
+ }
244
+ default: {
245
+ // Events without a cell (stop, …) — the generic render, byte-
246
+ // preserved for the pipe path (C5: Event → RenderInput first).
247
+ const input = toRenderInput(ev);
248
+ if (input === null)
249
+ break;
250
+ const rendered = renderEvent(input, false, canonicalTargetPath);
251
+ if (rendered.text !== "") {
252
+ body.raw(rendered.text.replace(/\n$/, "").split("\n"));
253
+ }
254
+ break;
255
+ }
256
+ }
257
+ }
258
+ body.thinkingEnd(); // a trailing thinking block folds at the run's end
259
+ }
260
+ finally {
261
+ }
262
+ return last;
263
+ }
264
+ /** Interactive REPL: stream events, pause for approvals, Ctrl+C aborts. */
265
+ export async function chat(session, faux, input, autoCompact) {
266
+ let currentRun = null;
267
+ let cancelled = false;
268
+ const turn = (text) => new Promise((resolve, reject) => {
269
+ queued = Math.max(0, queued - 1); // a queued turn starts
270
+ // v2a: the echo filter compares the user_input event against THIS
271
+ // turn's own input — lines that arrive ahead of their turn (piped
272
+ // bursts, queued replays) must not overwrite the reference.
273
+ liveInput.current = text;
274
+ const run = session.run(text);
275
+ currentRun = run;
276
+ turnNo += 1;
277
+ const myTurn = turnNo;
278
+ // v3 §03: the running state owns the status bar — the glyph
279
+ // rotates every 200ms; the idle state returns after the run.
280
+ runStart = Date.now();
281
+ runUsage = { in: null, out: null, cache: null, known: false };
282
+ const stopSpinner = startStatusSpinner((g) => {
283
+ runGlyph = g;
284
+ paintRunning();
285
+ });
286
+ (async () => {
287
+ let last;
288
+ try {
289
+ last = await consumeRun(session, run, input, myTurn, faux, liveInput, statusCb);
290
+ stopSpinner();
291
+ paintIdle();
292
+ currentRun = null;
293
+ // 八: a faux script that ran out of declared turns exits
294
+ // loudly with a non-zero status — never a silent status 0.
295
+ // 第四轮(对抗): the exhaustion is a CONTROLLED rejection of
296
+ // this turn's promise — it propagates through the chain to
297
+ // chat to main's finally/catch, never an orphaned
298
+ // unhandled rejection from the IIFE.
299
+ failOnFauxExhaustion(last, faux, input);
300
+ // 八: after EVERY turn the prompt is re-armed — the human
301
+ // never types blind after the first turn.
302
+ input.prompt();
303
+ // 手感批 C8: the opt-in auto-compact — checked AFTER the
304
+ // turn ended (the run's terminal is in the log, the ratio
305
+ // is post-run).
306
+ await maybeAutoCompact();
307
+ resolve();
308
+ }
309
+ catch (err) {
310
+ // A run failure must not freeze the REPL (review finding
311
+ // 11): surface it and re-arm the prompt.
312
+ if (err instanceof FauxExhaustionError) {
313
+ currentRun = null;
314
+ reject(err);
315
+ return;
316
+ }
317
+ console.error(`\n[run failed] ${err instanceof Error ? err.message : String(err)}\n`);
318
+ currentRun = null;
319
+ input.prompt();
320
+ resolve();
321
+ }
322
+ })();
323
+ });
324
+ input.onSigint(() => {
325
+ if (currentRun) {
326
+ // 八: Ctrl+C cancels BOTH the pending question (if one is
327
+ // awaiting a line) and the run — the run then writes its unique
328
+ // aborted terminal, which the consumer keeps consuming.
329
+ console.log("\n[aborting run]");
330
+ pendingAsk?.();
331
+ currentRun.abort();
332
+ }
333
+ else if (pendingAsk !== null) {
334
+ pendingAsk?.(); // a startup/trust question — cancel it
335
+ }
336
+ else if (input.line() === "") {
337
+ cancelled = true;
338
+ console.log("\n[exit requested]");
339
+ input.close();
340
+ }
341
+ else {
342
+ input.clearLine(); // v2c: Ctrl+C on a non-empty line clears it
343
+ }
344
+ });
345
+ input.onEot(() => {
346
+ if (!currentRun && pendingAsk === null && input.line() === "") {
347
+ cancelled = true;
348
+ console.log("\n[exit requested]");
349
+ input.close();
350
+ }
351
+ });
352
+ input.onEscape(() => {
353
+ if (currentRun) {
354
+ console.log("\n[aborting run]");
355
+ pendingAsk?.();
356
+ currentRun.abort();
357
+ }
358
+ });
359
+ // 第五轮(P1-11): the PERSISTENT line listener is installed BEFORE the
360
+ // startup recovery — a cancelled question's re-emitted "line" needs a
361
+ // listener from the very first instant, or the input is silently lost.
362
+ // Turns are SERIALIZED on a chain — piped lines arrive faster than
363
+ // turns complete, and concurrent runs are forbidden. Lines that arrive
364
+ // while the recovery is still running are QUEUED and replayed once the
365
+ // REPL is ready (they are never dropped).
366
+ const chainRef = { current: Promise.resolve() };
367
+ let replReady = false;
368
+ const queuedLines = [];
369
+ // B 区: user-turn counter for the status line. /last and /think read
370
+ // the body (the ToolCell / ThinkingCell final states).
371
+ let turnNo = 0;
372
+ // v2a: the last line THIS process's readline consumed — the double-echo
373
+ // filter (see consumeRun). Only interactive chat sets it.
374
+ const liveInput = { current: null };
375
+ // v2c: turns submitted while another runs are QUEUED on the chain — the
376
+ // live count rides the status bar (+N queued).
377
+ let queued = 0;
378
+ // v2b: the live status bar (docked only). Modes: /mode switches repaint
379
+ // it immediately through paintStatus (the last turn stats are kept).
380
+ // v3 §03: the status bar has TWO states. Idle: the mode is ALWAYS
381
+ // shown (default included) with the /mode hint. Running: the working
382
+ // glyph (▖▘▝▗ — the spinner drives it) + wall seconds + ↓ out tokens
383
+ // + the interrupt hint. ctx left is the live estimate everywhere.
384
+ let runUsage = { in: null, out: null, cache: null, known: false };
385
+ let runGlyph = "▖";
386
+ let runStart = Date.now();
387
+ const paintRunning = () => {
388
+ if (!dock.active)
389
+ return;
390
+ const ratio = estimateCtxRatio(session);
391
+ const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
392
+ const out = runUsage.out !== null ? ` ↓ ${kUnit(runUsage.out)} tokens` : "";
393
+ dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
394
+ };
395
+ const paintIdle = () => {
396
+ if (!dock.active)
397
+ return;
398
+ const ratio = estimateCtxRatio(session);
399
+ const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
400
+ dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
401
+ };
402
+ const statusCb = (u, ctx) => {
403
+ runUsage = u;
404
+ paintRunning();
405
+ };
406
+ const submitTurn = (line) => {
407
+ queued += 1;
408
+ chainRef.current = chainRef.current.then(() => turn(line));
409
+ };
410
+ const dispatchCtx = {
411
+ session,
412
+ input,
413
+ chainRef,
414
+ isRunning: () => currentRun !== null,
415
+ paintIdle,
416
+ submitTurn,
417
+ estimateCtx: () => estimateCtxRatio(session),
418
+ };
419
+ // 手感批 C8: the auto-compact check — the /compact FULL path via the
420
+ // shared dispatch (same notices, same chain ordering, same mid-run
421
+ // refusal — the isRunning guard here only avoids the refusal's noise).
422
+ // The appended segment is NOT awaited here on purpose: from inside a
423
+ // chain segment, awaiting the append would be circular (the segment
424
+ // chains after THIS segment's promise). The exit path re-awaits the
425
+ // chain once more after the turn — see the final awaits in chat().
426
+ const maybeAutoCompact = () => {
427
+ if (autoCompact === undefined)
428
+ return;
429
+ if (currentRun !== null)
430
+ return; // dispatch would refuse — skip the noise
431
+ const ratio = estimateCtxRatio(session);
432
+ if (!Number.isFinite(ratio) || ratio < autoCompact.thresholdRatio)
433
+ return;
434
+ dispatch("/compact", dispatchCtx);
435
+ };
436
+ input.onLine((line) => {
437
+ if (!replReady) {
438
+ queuedLines.push(line);
439
+ return;
440
+ }
441
+ dispatch(line, dispatchCtx);
442
+ });
443
+ // Recovery first: a session with a dangling pause or uncertain
444
+ // executions must resolve them BEFORE the REPL accepts new turns —
445
+ // otherwise the interrupted run dangles while a new one starts.
446
+ // 八: the startup resume is bound to currentRun — Ctrl+C during it
447
+ // aborts the recovery, exactly like the interactive turns.
448
+ await resolveUncertains(session, input, () => cancelled);
449
+ if (!cancelled) {
450
+ const recoveryRun = session.resume();
451
+ currentRun = recoveryRun;
452
+ turnNo += 1;
453
+ const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
454
+ currentRun = null;
455
+ failOnFauxExhaustion(last, faux, input);
456
+ maybeAutoCompact(); // 手感批 C8: the recovery run ended too — same check (awaited by the exit re-await)
457
+ }
458
+ if (cancelled) {
459
+ input.close();
460
+ await input.closed;
461
+ return;
462
+ }
463
+ // The REPL is ready: replay anything that arrived during recovery.
464
+ replReady = true;
465
+ // v2c: dispatch SYNCHRONOUSLY — each call appends its segment to the
466
+ // chain variable; the final `await chain` then covers every replayed
467
+ // turn. A chain.then(() => dispatch()) indirection would capture the
468
+ // chain BEFORE the appends and the replayed turns would never be
469
+ // awaited (the F-group regression).
470
+ for (const line of queuedLines) {
471
+ dispatch(line, dispatchCtx);
472
+ }
473
+ queuedLines.length = 0;
474
+ input.prompt();
475
+ await input.closed;
476
+ await chainRef.current; // never exit while a turn is in flight
477
+ // 手感批 C8: the auto-compact may have appended ITS segment inside the
478
+ // turn (the check runs at the turn's end, after the exit-await above
479
+ // already captured the chain) — re-await once so the summarize either
480
+ // runs before the exit or the chain is already settled. One level is
481
+ // enough: the /compact segment appends nothing of its own.
482
+ await chainRef.current;
483
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 手感批 B4 (pure move) — the ONE dispatcher: slash commands, exit, and
3
+ * turns. The bodies moved verbatim from chat()'s closure; chat provides
4
+ * the context (the chain, the run state, the prompt arming).
5
+ */
6
+ import type { AgentSession } from "@vincemakes/kiso-runtime";
7
+ import { type LineInput } from "./state.js";
8
+ /** Everything dispatch touches that chat() owns. */
9
+ export interface DispatchCtx {
10
+ readonly session: AgentSession;
11
+ readonly input: LineInput;
12
+ /** the turn chain — every dispatch segment appends onto it (the queue
13
+ * replay and the REPL share the chain). */
14
+ readonly chainRef: {
15
+ current: Promise<void>;
16
+ };
17
+ /** true while a run is in flight — /compact refuses mid-run. */
18
+ readonly isRunning: () => boolean;
19
+ /** the /mode switch repaints the status bar at once. */
20
+ readonly paintIdle: () => void;
21
+ /** submit a real turn: queue + chain (the turn closure lives in chat). */
22
+ readonly submitTurn: (line: string) => void;
23
+ /** the /status context estimate. */
24
+ readonly estimateCtx: () => number;
25
+ }
26
+ /** The ONE dispatcher — slash commands, exit, and turns. The recovery
27
+ * replay routes through it too — a queued "/last" must never become a
28
+ * user turn (v2c: the rl lives in main, so lines arrive earlier and the
29
+ * queue is the common path). */
30
+ export declare function dispatch(line: string, ctx: DispatchCtx): void;