@vincemakes/kiso-code 0.1.20 → 0.1.21
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 +49 -0
- package/dist/chat.js +473 -0
- package/dist/dispatch.d.ts +30 -0
- package/dist/dispatch.js +138 -0
- package/dist/faux-glue.d.ts +44 -0
- package/dist/faux-glue.js +115 -0
- package/dist/index.d.ts +8 -8
- package/dist/index.js +31 -985
- package/dist/resume.d.ts +14 -0
- package/dist/resume.js +104 -0
- package/dist/state.d.ts +71 -0
- package/dist/state.js +78 -0
- package/dist/trust-ui.d.ts +50 -0
- package/dist/trust-ui.js +218 -0
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -14,54 +14,31 @@
|
|
|
14
14
|
* Sessions live under $KISO_HOME/sessions (default ~/.kiso/sessions) as
|
|
15
15
|
* append-only JSONL. Write/edit/shell tools sit behind the approval policy:
|
|
16
16
|
* the run pauses, asks, and resumes — durably (ADR-0024).
|
|
17
|
+
*
|
|
18
|
+
* 手感批 B4 (pure move): the interactive pieces live beside this file —
|
|
19
|
+
* chat.ts (the REPL + consumeRun), dispatch.ts (the slash dispatcher),
|
|
20
|
+
* resume.ts, trust-ui.ts (the question surface + E3 merges), faux-glue.ts
|
|
21
|
+
* (the scripted-model plumbing), state.ts (the shared process state).
|
|
22
|
+
* index.ts keeps the entry: banner, input sources, the A 区 prompt,
|
|
23
|
+
* makeAgent, and main.
|
|
17
24
|
*/
|
|
18
|
-
import {
|
|
25
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
19
26
|
import { createInterface } from "node:readline";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { homedir, tmpdir } from "node:os";
|
|
25
|
-
import { dirname, join } from "node:path";
|
|
26
|
-
import { fileURLToPath } from "node:url";
|
|
27
|
-
import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, projectArtifacts, recordTrust, SessionStore, trustFor, } from "@vincemakes/kiso-runtime";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { Body, Editor, PROMPT as EDITOR_PROMPT, bannerLines, palette, renderSessionLine } from "@vincemakes/kiso-tui";
|
|
29
|
+
import { escapeTerminal } from "@vincemakes/kiso-tui";
|
|
30
|
+
import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, SessionStore, } from "@vincemakes/kiso-runtime";
|
|
28
31
|
import { createFauxProvider } from "@vincemakes/kiso-evals";
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
function sessionsDir() {
|
|
40
|
-
return join(kisoHome(), "sessions");
|
|
41
|
-
}
|
|
42
|
-
/** E1: the extension scan directory — KISO_EXTENSIONS_DIR overrides. */
|
|
43
|
-
function extensionsDir() {
|
|
44
|
-
return process.env.KISO_EXTENSIONS_DIR ?? join(kisoHome(), "extensions");
|
|
45
|
-
}
|
|
46
|
-
/** E1: the extensions loaded by makeAgent — their names feed the banner. */
|
|
47
|
-
let loadedExtensions = [];
|
|
48
|
-
/** E1: the USER-level extensions alone — the banner's unmarked part (E3:
|
|
49
|
-
* loadedExtensions later includes the project-level ones too). */
|
|
50
|
-
let userExtensions = [];
|
|
51
|
-
/** E3: the PROJECT-level extensions (loaded after the trust gate) — the
|
|
52
|
-
* banner distinguishes them from the user-level ones. */
|
|
53
|
-
let projectExtensions = [];
|
|
54
|
-
/** E3: temp artifacts of the mcp/skills merge — removed on exit. */
|
|
55
|
-
const mergedTempPaths = [];
|
|
56
|
-
/** The CLI's own version — read from the package.json next to the build. */
|
|
57
|
-
let VERSION = "?";
|
|
58
|
-
try {
|
|
59
|
-
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
|
|
60
|
-
VERSION = pkg.version ?? "?";
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// a packed CLI without a readable package.json still works
|
|
64
|
-
}
|
|
32
|
+
import { createCodingTools } from "@vincemakes/kiso-tools-node";
|
|
33
|
+
import { MODES, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
|
|
34
|
+
import { body, bodyLog, dock, extensionsDir, loadedExtensions, mergedTempPaths, projectExtensions, sessionsDir, setAgentModel, setBody, setExtensionLists, userExtensions, VERSION } from "./state.js";
|
|
35
|
+
import { interactivePrompt, resolveProjectTrust } from "./trust-ui.js";
|
|
36
|
+
import { fauxSkip, readFauxScript } from "./faux-glue.js";
|
|
37
|
+
import { autoCompactFromEnv, chat, contextWindowTokens } from "./chat.js";
|
|
38
|
+
import { resume } from "./resume.js";
|
|
39
|
+
// The moved exports stay reachable from this entry — the test imports
|
|
40
|
+
// (project-trust, coding-agent) never change (B4: 断言零改动).
|
|
41
|
+
export { applyProjectMerges } from "./trust-ui.js";
|
|
65
42
|
/** 横幅: the block-letter logo (design fixed). TTY only — pipes, e2e
|
|
66
43
|
* drivers, and CI see byte-for-byte the old output; the extensions line
|
|
67
44
|
* merges into the third row on TTY and stays a standalone line off-TTY.
|
|
@@ -80,14 +57,6 @@ function startupBanner() {
|
|
|
80
57
|
const rows = bannerLines(W > 0 ? W : 80, VERSION, bannerExtensionText().replace(/^ · /, ""));
|
|
81
58
|
return `${rows.map((r) => `${p.dim}${r}${p.reset}`).join("\n")}\n`;
|
|
82
59
|
}
|
|
83
|
-
/** v2a: the interactive prompt — blue, the identity accent. readline owns
|
|
84
|
-
* the echo of what the user types; we own the prompt's color. (v2c: the
|
|
85
|
-
* readline prompt keeps "you> " — the brick ▌ is the dock's row only;
|
|
86
|
-
* pipe bytes must not change.) */
|
|
87
|
-
function interactivePrompt() {
|
|
88
|
-
const p = palette();
|
|
89
|
-
return `${p.blue}you> ${p.reset}`;
|
|
90
|
-
}
|
|
91
60
|
/** The v2b behavior, unchanged: readline owns the line, SIGINT, and the
|
|
92
61
|
* prompt. Only ever constructed when stdin is NOT a TTY. The rl starts
|
|
93
62
|
* consuming stdin at construction (main), so 'line' events are buffered
|
|
@@ -200,37 +169,6 @@ function makeLineInput() {
|
|
|
200
169
|
}
|
|
201
170
|
return readlineInput(createInterface({ input: process.stdin, output: process.stdout }));
|
|
202
171
|
}
|
|
203
|
-
/** v2b: the bottom-anchored UI — docked only on a color TTY; pipes and
|
|
204
|
-
* NO_COLOR stay the v2a line mode byte-for-byte. */
|
|
205
|
-
const dock = new Dock();
|
|
206
|
-
/** v2d: the body renderer — the ONE writer of the stdout scroll region
|
|
207
|
-
* (the frozen area + the active tail). Pipes run it in passthrough (the
|
|
208
|
-
* v2b/v2c line-mode bytes, byte-for-byte). Created in main; closed on
|
|
209
|
-
* every exit path. */
|
|
210
|
-
let body;
|
|
211
|
-
/** v2d: body output routes through the cell renderer — the single writer.
|
|
212
|
-
* bodyLog adds the trailing newline; internal newlines are preserved. */
|
|
213
|
-
function bodyLog(text) {
|
|
214
|
-
body.raw(text.split("\n"));
|
|
215
|
-
}
|
|
216
|
-
/** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
|
|
217
|
-
* is gone) — docked only, 200ms rotation between the request and the
|
|
218
|
-
* first event. */
|
|
219
|
-
function startStatusSpinner(onTick) {
|
|
220
|
-
if (!dock.active)
|
|
221
|
-
return () => { };
|
|
222
|
-
// v3 §03/§05: the working glyph family ▖▘▝▗, 200ms rotation — the
|
|
223
|
-
// callback repaints the running status line with the new glyph.
|
|
224
|
-
const GLYPHS = ["▖", "▘", "▝", "▗"];
|
|
225
|
-
let i = 0;
|
|
226
|
-
const timer = setInterval(() => onTick(GLYPHS[i++ % GLYPHS.length]), 200);
|
|
227
|
-
timer.unref();
|
|
228
|
-
return () => clearInterval(timer);
|
|
229
|
-
}
|
|
230
|
-
/** v3 §03: "running <tool> Ns" is gone — the running status line owns
|
|
231
|
-
* the wall clock; the per-tool timer was the old tail mechanism. */
|
|
232
|
-
/** The model name for the status bar — set by makeAgent. */
|
|
233
|
-
let agentModel = "faux";
|
|
234
172
|
/** E3: the `[N extensions: ...]` text — user-level names, then project-level
|
|
235
173
|
* ones marked with `project:`. Byte-identical to the historical text when
|
|
236
174
|
* no project extensions are loaded. */
|
|
@@ -257,134 +195,6 @@ function extensionsBanner() {
|
|
|
257
195
|
return;
|
|
258
196
|
bodyLog(`${text}\n`);
|
|
259
197
|
}
|
|
260
|
-
/**
|
|
261
|
-
* E3 — the project-level trust gate (ADR-0037): capability is trusted by
|
|
262
|
-
* content digest, not by directory. Runs BEFORE any extension loads — the
|
|
263
|
-
* mcp/skills merges must be in the env before the user-level extensions are
|
|
264
|
-
* loaded (the mcp factory reads KISO_MCP_CONFIG at load time, the skills
|
|
265
|
-
* extension scans KISO_SKILLS_DIR at load time).
|
|
266
|
-
*
|
|
267
|
-
* Verdicts: granted → load; refused → never load, never re-ask (refused is
|
|
268
|
-
* sticky — re-evaluate by deleting the trust line or changing a file); no
|
|
269
|
-
* record → only a HUMAN may decide, TTY only — non-TTY refuses with one
|
|
270
|
-
* stderr line. Returns the artifacts on grant, null on anything else.
|
|
271
|
-
*/
|
|
272
|
-
async function resolveProjectTrust(input) {
|
|
273
|
-
const artifacts = await projectArtifacts(process.cwd());
|
|
274
|
-
if (artifacts === null)
|
|
275
|
-
return null; // no .kiso artifacts — nothing to gate
|
|
276
|
-
const record = trustFor(artifacts.root, artifacts.digest);
|
|
277
|
-
if (record?.decision === "granted") {
|
|
278
|
-
applyProjectMerges(artifacts);
|
|
279
|
-
return artifacts;
|
|
280
|
-
}
|
|
281
|
-
if (record?.decision === "refused")
|
|
282
|
-
return null; // refused is sticky — no re-ask
|
|
283
|
-
// First discovery — list every artifact (file name + digest short
|
|
284
|
-
// prefix) and ask the human ONCE.
|
|
285
|
-
if (!process.stdin.isTTY) {
|
|
286
|
-
console.error(`[project .kiso] found ${artifacts.files.length} artifact(s) in ${artifacts.root} — not trusted, not loaded (run kiso interactively once to decide)`);
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
// v2c: the shared input (the editor on a TTY) reads the answer; the
|
|
290
|
-
// dock shows the question at the status position.
|
|
291
|
-
bodyLog(`[project .kiso] ${artifacts.root}`);
|
|
292
|
-
for (const f of artifacts.files) {
|
|
293
|
-
bodyLog(` ${f.path} (${f.digest.slice(0, 6)})`);
|
|
294
|
-
}
|
|
295
|
-
const answer = await ask(input, `trust this project's .kiso? (y/n) `);
|
|
296
|
-
const granted = answer !== CANCELLED && answer.trim().toLowerCase().startsWith("y");
|
|
297
|
-
recordTrust({ root: artifacts.root, digest: artifacts.digest, decision: granted ? "granted" : "refused" });
|
|
298
|
-
if (!granted)
|
|
299
|
-
return null;
|
|
300
|
-
applyProjectMerges(artifacts);
|
|
301
|
-
return artifacts;
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* E3 — merge the project's mcp.json and skills into the env BEFORE the
|
|
305
|
-
* extension load. A server name in BOTH configs is a LOUD error (a silent
|
|
306
|
-
* override would be a supply-chain surprise); a skill name in both merges
|
|
307
|
-
* with project-wins and a stderr note. Exported for tests.
|
|
308
|
-
*/
|
|
309
|
-
export function applyProjectMerges(artifacts) {
|
|
310
|
-
if (artifacts.files.some((f) => f.kind === "mcp"))
|
|
311
|
-
applyMcpMerge(artifacts.root);
|
|
312
|
-
if (artifacts.files.some((f) => f.kind === "skill"))
|
|
313
|
-
applySkillsMerge(artifacts.root);
|
|
314
|
-
}
|
|
315
|
-
/** Read an mcp.json with the mcp extension's tolerance: absent/unreadable →
|
|
316
|
-
* {}, present-but-broken → throw (the loader convention). */
|
|
317
|
-
function readMcpConfig(path) {
|
|
318
|
-
let text;
|
|
319
|
-
try {
|
|
320
|
-
text = readFileSync(path, "utf8");
|
|
321
|
-
}
|
|
322
|
-
catch {
|
|
323
|
-
return {};
|
|
324
|
-
}
|
|
325
|
-
let parsed;
|
|
326
|
-
try {
|
|
327
|
-
parsed = JSON.parse(text);
|
|
328
|
-
}
|
|
329
|
-
catch (err) {
|
|
330
|
-
throw new Error(`[project .kiso] cannot parse ${path}: ${err.message}`);
|
|
331
|
-
}
|
|
332
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
333
|
-
throw new Error(`[project .kiso] ${path} must be an object with an mcpServers map`);
|
|
334
|
-
}
|
|
335
|
-
return parsed;
|
|
336
|
-
}
|
|
337
|
-
/** Merge user-level + project-level mcp.json into one temp file and point
|
|
338
|
-
* KISO_MCP_CONFIG at it — the mcp extension reads it at load time. */
|
|
339
|
-
function applyMcpMerge(root) {
|
|
340
|
-
const userPath = process.env.KISO_MCP_CONFIG ?? join(kisoHome(), "mcp.json");
|
|
341
|
-
const user = readMcpConfig(userPath);
|
|
342
|
-
const project = readMcpConfig(join(root, "mcp.json"));
|
|
343
|
-
const userServers = user.mcpServers ?? {};
|
|
344
|
-
const projectServers = project.mcpServers ?? {};
|
|
345
|
-
if (Object.keys(projectServers).length === 0)
|
|
346
|
-
return; // nothing to merge
|
|
347
|
-
for (const name of Object.keys(projectServers)) {
|
|
348
|
-
if (name in userServers) {
|
|
349
|
-
throw new Error(`[project .kiso] mcp server "${name}" exists in both the user-level and the project-level mcp.json`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
const merged = { mcpServers: { ...userServers, ...projectServers } };
|
|
353
|
-
const temp = join(tmpdir(), `kiso-mcp-merged-${process.pid}.json`);
|
|
354
|
-
writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
355
|
-
process.env.KISO_MCP_CONFIG = temp;
|
|
356
|
-
mergedTempPaths.push(temp);
|
|
357
|
-
}
|
|
358
|
-
/** Merge user-level + project-level skills into one temp scan dir (project
|
|
359
|
-
* skill dirs symlinked first; a name in both → project wins + a stderr
|
|
360
|
-
* note) and point KISO_SKILLS_DIR at it — the skills extension's existing
|
|
361
|
-
* scan reads it at load time and per read_skill call. */
|
|
362
|
-
function applySkillsMerge(root) {
|
|
363
|
-
const userDir = process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
|
|
364
|
-
const projectDir = join(root, "skills");
|
|
365
|
-
const merged = mkdtempSync(join(tmpdir(), "kiso-skills-"));
|
|
366
|
-
mergedTempPaths.push(merged);
|
|
367
|
-
for (const dir of readdirSyncSafe(projectDir)) {
|
|
368
|
-
symlinkSync(join(projectDir, dir), join(merged, dir)); // project wins on collision
|
|
369
|
-
}
|
|
370
|
-
for (const dir of readdirSyncSafe(userDir)) {
|
|
371
|
-
const target = join(merged, dir);
|
|
372
|
-
if (existsSync(target)) {
|
|
373
|
-
console.error(`[project .kiso] skill "${dir}" exists in both user and project skills — project wins`);
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
symlinkSync(join(userDir, dir), target);
|
|
377
|
-
}
|
|
378
|
-
process.env.KISO_SKILLS_DIR = merged;
|
|
379
|
-
}
|
|
380
|
-
function readdirSyncSafe(dir) {
|
|
381
|
-
try {
|
|
382
|
-
return readdirSync(dir);
|
|
383
|
-
}
|
|
384
|
-
catch {
|
|
385
|
-
return []; // no skills dir on either level = nothing to merge
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
198
|
/**
|
|
389
199
|
* A 区: the coding-agent system prompt — ONE constant, byte-stable for the
|
|
390
200
|
* session's lifetime (D 区). Kept under ~80 lines; no template engine.
|
|
@@ -440,26 +250,6 @@ export function composeSystemPrompt(cwd) {
|
|
|
440
250
|
const injected = readProjectInstructions(cwd);
|
|
441
251
|
return injected === "" ? SYSTEM_PROMPT : `${SYSTEM_PROMPT}\n${injected}`;
|
|
442
252
|
}
|
|
443
|
-
/**
|
|
444
|
-
* E 区: how many faux-script turns a session has already consumed. The faux
|
|
445
|
-
* provider's script counter is per-process, so a FRESH process that resumes
|
|
446
|
-
* a session would restart the script at turn 0 — re-issuing the first
|
|
447
|
-
* scripted call instead of continuing the trajectory. The session log is
|
|
448
|
-
* the durable position: a turn is consumed when it produced a tool_result
|
|
449
|
-
* or an end_turn stop — AND when its tool call is unfinished (started but
|
|
450
|
-
* no result): the recovery completes those turns WITHOUT a provider call
|
|
451
|
-
* (executes the approved call, or fills the human verdict), so the model's
|
|
452
|
-
* next response is the turn AFTER them.
|
|
453
|
-
*/
|
|
454
|
-
function fauxSkip(id) {
|
|
455
|
-
const events = new SessionStore(sessionsDir())
|
|
456
|
-
.load(id)
|
|
457
|
-
.map((r) => r.event);
|
|
458
|
-
const results = new Set(events.filter((e) => e.type === "tool_result").map((e) => e.callId));
|
|
459
|
-
return (events.filter((e) => e.type === "tool_result").length +
|
|
460
|
-
events.filter((e) => e.type === "stop" && e.reason === "end_turn").length +
|
|
461
|
-
events.filter((e) => e.type === "tool_call_end" && !results.has(e.callId)).length);
|
|
462
|
-
}
|
|
463
253
|
async function makeAgent(fauxSkipTurns = 0, input) {
|
|
464
254
|
const store = new SessionStore(sessionsDir());
|
|
465
255
|
// E3: the project-level trust gate runs BEFORE any extension load (the
|
|
@@ -468,14 +258,13 @@ async function makeAgent(fauxSkipTurns = 0, input) {
|
|
|
468
258
|
const project = input !== undefined ? await resolveProjectTrust(input) : await resolveProjectTrust(undefined);
|
|
469
259
|
// E1: the startup extension scan — a broken extension fails the process
|
|
470
260
|
// LOUDLY here (loadExtensions throws), never silently.
|
|
471
|
-
|
|
261
|
+
const user = await loadExtensions(extensionsDir());
|
|
472
262
|
if (project !== null) {
|
|
473
|
-
|
|
474
|
-
|
|
263
|
+
const proj = await loadProjectExtensions(process.cwd(), user);
|
|
264
|
+
setExtensionLists(user, proj, [...user, ...proj]);
|
|
475
265
|
}
|
|
476
266
|
else {
|
|
477
|
-
|
|
478
|
-
loadedExtensions = userExtensions;
|
|
267
|
+
setExtensionLists(user, [], user);
|
|
479
268
|
}
|
|
480
269
|
// Provider wiring (F 组): the CLI never imports provider SDKs directly —
|
|
481
270
|
// the runtime's lazy provider resolution owns them. Real key → real
|
|
@@ -496,7 +285,7 @@ async function makeAgent(fauxSkipTurns = 0, input) {
|
|
|
496
285
|
console.log("[faux mode — set ANTHROPIC_API_KEY or OPENAI_API_KEY for a real model]\n");
|
|
497
286
|
model = "faux";
|
|
498
287
|
}
|
|
499
|
-
|
|
288
|
+
setAgentModel(model); // v2b: the status bar shows it
|
|
500
289
|
const definition = {
|
|
501
290
|
model,
|
|
502
291
|
store,
|
|
@@ -533,749 +322,6 @@ async function makeAgent(fauxSkipTurns = 0, input) {
|
|
|
533
322
|
};
|
|
534
323
|
return createAgent(definition);
|
|
535
324
|
}
|
|
536
|
-
/**
|
|
537
|
-
* E 区: KISO_FAUX_SCRIPT=<path> overrides the demo script with a JSON
|
|
538
|
-
* FauxScript file — the kill -9 e2e drives the CLI through an exact
|
|
539
|
-
* multi-tool trajectory. Absent → the built-in demo script.
|
|
540
|
-
*/
|
|
541
|
-
function readFauxScript() {
|
|
542
|
-
const path = process.env.KISO_FAUX_SCRIPT;
|
|
543
|
-
if (path === undefined)
|
|
544
|
-
return fauxScript();
|
|
545
|
-
try {
|
|
546
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
547
|
-
if (!Array.isArray(parsed))
|
|
548
|
-
throw new Error("not an array");
|
|
549
|
-
return parsed;
|
|
550
|
-
}
|
|
551
|
-
catch (err) {
|
|
552
|
-
console.error(`[KISO_FAUX_SCRIPT] cannot load ${path}: ${err.message}`);
|
|
553
|
-
process.exit(1);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
/**
|
|
557
|
-
* The keyless demo script: tours the tools so `kiso chat` exercises them.
|
|
558
|
-
* FOUR turns: each user turn consumes two model rounds (call → result →
|
|
559
|
-
* summary), so at least two consecutive user turns work in one process
|
|
560
|
-
* (F 组).
|
|
561
|
-
*/
|
|
562
|
-
function fauxScript() {
|
|
563
|
-
return [
|
|
564
|
-
{
|
|
565
|
-
events: [
|
|
566
|
-
// 自举 P1: a multi-delta thinking block — renders as ONE
|
|
567
|
-
// streaming segment, not one line per token.
|
|
568
|
-
{ type: "thinking", text: "Let me think about" },
|
|
569
|
-
{ type: "thinking", text: " the workspace" },
|
|
570
|
-
{ type: "thinking", text: " before acting." },
|
|
571
|
-
{ type: "text_start" },
|
|
572
|
-
{ type: "text_delta", text: "I'm the faux model. Let me look at the working directory." },
|
|
573
|
-
{ type: "tool_call_end", callId: "c1", name: "list_dir", input: {} },
|
|
574
|
-
{ type: "stop", reason: "tool_use" },
|
|
575
|
-
],
|
|
576
|
-
},
|
|
577
|
-
{
|
|
578
|
-
events: [
|
|
579
|
-
{ type: "text_delta", text: "I see the workspace. What would you like me to inspect or change?" },
|
|
580
|
-
{ type: "stop", reason: "end_turn" },
|
|
581
|
-
],
|
|
582
|
-
},
|
|
583
|
-
{
|
|
584
|
-
events: [
|
|
585
|
-
{ type: "text_delta", text: "The faux model is still here, with full context." },
|
|
586
|
-
{ type: "stop", reason: "end_turn" },
|
|
587
|
-
],
|
|
588
|
-
},
|
|
589
|
-
{
|
|
590
|
-
events: [
|
|
591
|
-
{ type: "text_delta", text: "And this is the end of the scripted tour." },
|
|
592
|
-
{ type: "stop", reason: "end_turn" },
|
|
593
|
-
],
|
|
594
|
-
},
|
|
595
|
-
];
|
|
596
|
-
}
|
|
597
|
-
/** 十: a question cancelled by Ctrl+C — NEVER the empty string, which is a
|
|
598
|
-
* real user answer (the empty line). The empty answer and the cancellation
|
|
599
|
-
* are distinct facts. */
|
|
600
|
-
const CANCELLED = Symbol("kiso-question-cancelled");
|
|
601
|
-
/**
|
|
602
|
-
* Ask the human a question. Non-interactive stdin (piped, CI) cannot wait
|
|
603
|
-
* forever: approvals auto-deny and uncertain executions auto-abandon, both
|
|
604
|
-
* printed loudly — never silently ignored, never hung (Area 7).
|
|
605
|
-
*
|
|
606
|
-
* 八/十: the question is ABORTABLE — a pending rl.question is registered in
|
|
607
|
-
* `pendingAsk` and the SIGINT handler resolves it with the CANCELLED
|
|
608
|
-
* sentinel. The rl.question callback is NOT left dangling: an input that
|
|
609
|
-
* arrives after the cancellation is re-emitted as a fresh "line" — it
|
|
610
|
-
* becomes the next user turn instead of being swallowed by the dead
|
|
611
|
-
* question.
|
|
612
|
-
*/
|
|
613
|
-
let pendingAsk = null;
|
|
614
|
-
function ask(input, question) {
|
|
615
|
-
if (!process.stdin.isTTY) {
|
|
616
|
-
console.log(`[non-interactive — no human to ask: ${question}]`);
|
|
617
|
-
return Promise.resolve("");
|
|
618
|
-
}
|
|
619
|
-
// v2b: docked — the question takes over the status position, the
|
|
620
|
-
// answer lands at the input line. v2c: a TTY without a dock (rows < 4)
|
|
621
|
-
// prints the question into the body — the editor cannot show it.
|
|
622
|
-
if (dock.active) {
|
|
623
|
-
dock.showQuestion(question);
|
|
624
|
-
}
|
|
625
|
-
else {
|
|
626
|
-
bodyLog(question);
|
|
627
|
-
}
|
|
628
|
-
return new Promise((resolve) => {
|
|
629
|
-
let settled = false;
|
|
630
|
-
pendingAsk = () => {
|
|
631
|
-
if (settled)
|
|
632
|
-
return;
|
|
633
|
-
settled = true;
|
|
634
|
-
pendingAsk = null;
|
|
635
|
-
input.cancelQuestion();
|
|
636
|
-
resolve(CANCELLED); // the run is aborting — the question is dead
|
|
637
|
-
};
|
|
638
|
-
// v2b: docked — the question reads at the input line, whose prompt
|
|
639
|
-
// is the same blue you> (the editor's brick row; the readline path
|
|
640
|
-
// passes the plain question). An empty prompt would start readline
|
|
641
|
-
// at column 1 while the dock renders "you> " — the typed answer
|
|
642
|
-
// would land on the prompt and drift (probe-confirmed).
|
|
643
|
-
input.question(dock.active ? interactivePrompt() : question, (answer) => {
|
|
644
|
-
if (settled) {
|
|
645
|
-
// The question was cancelled; this line is a NEW user turn.
|
|
646
|
-
input.emitLine(answer);
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
settled = true;
|
|
650
|
-
pendingAsk = null;
|
|
651
|
-
if (dock.active)
|
|
652
|
-
dock.clearQuestion();
|
|
653
|
-
resolve(answer);
|
|
654
|
-
});
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* C 区: the model window in tokens — KISO_CONTEXT_WINDOW overrides the
|
|
659
|
-
* 200k default. The microcompact threshold is derived from it (50%), and
|
|
660
|
-
* the status line's ~ctx estimate is measured against it — one source of
|
|
661
|
-
* truth for the window.
|
|
662
|
-
*/
|
|
663
|
-
function contextWindowTokens() {
|
|
664
|
-
const window = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
|
|
665
|
-
return Number.isFinite(window) && window > 0 ? window : DEFAULT_CONTEXT_WINDOW;
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* B 区: approximate context ratio — chars/4 of the projected messages vs
|
|
669
|
-
* the model window. Marked ~ everywhere it is shown; no counting API.
|
|
670
|
-
*/
|
|
671
|
-
function estimateCtxRatio(session) {
|
|
672
|
-
const projected = session.projected();
|
|
673
|
-
const chars = JSON.stringify(projected).length;
|
|
674
|
-
return chars / 4 / contextWindowTokens();
|
|
675
|
-
}
|
|
676
|
-
/** Decide every uncertain execution with the human (r)erun/(a)bandon. */
|
|
677
|
-
async function resolveUncertains(session, input, isCancelled) {
|
|
678
|
-
for (const uncertain of session.uncertainExecutions()) {
|
|
679
|
-
const answer = await ask(input, `⚠ interrupted execution: ${escapeTerminal(uncertain.name)} (${uncertain.executionId}) — did it apply? (r)erun / (a)bandon: `);
|
|
680
|
-
if (isCancelled() || answer === CANCELLED) {
|
|
681
|
-
// 十: a cancellation NEVER records a verdict — the execution
|
|
682
|
-
// stays uncertain and durable; no rerun/abandoned is fabricated.
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
const resolution = answer.trim().toLowerCase().startsWith("r") ? "rerun" : "abandoned";
|
|
686
|
-
await session.resolveUncertain(uncertain.executionId, resolution);
|
|
687
|
-
console.log(` ${resolution}\n`);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
/** B 区: default context window for the ~ctx estimate (config overridable). */
|
|
691
|
-
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
692
|
-
/**
|
|
693
|
-
* Consume a run, answering approval pauses as they arrive. `resumeMode`
|
|
694
|
-
* marks a session.resume() continuation. v2a: `faux` picks the status
|
|
695
|
-
* line's form; `liveInput` (non-null only in interactive chat) carries the
|
|
696
|
-
* last line THIS process's readline consumed — the double-echo filter.
|
|
697
|
-
*/
|
|
698
|
-
/** v2e: the approval-moment mini-diff — edit_file/write_file changes as
|
|
699
|
-
* ± lines; other tools get null (no diff, no cost). The file read is
|
|
700
|
-
* best-effort: an unreadable file yields NO diff, never a failure —
|
|
701
|
-
* the diff must never break the approval. */
|
|
702
|
-
function approvalDiff(name, input) {
|
|
703
|
-
if (name !== "edit_file" && name !== "write_file")
|
|
704
|
-
return null;
|
|
705
|
-
const path = typeof input.path === "string" ? input.path : "";
|
|
706
|
-
if (path === "")
|
|
707
|
-
return null;
|
|
708
|
-
let oldContent = null;
|
|
709
|
-
try {
|
|
710
|
-
oldContent = readFileSync(path, "utf8");
|
|
711
|
-
}
|
|
712
|
-
catch {
|
|
713
|
-
// a new write_file target (or an unreadable one) — all + degrades
|
|
714
|
-
}
|
|
715
|
-
try {
|
|
716
|
-
if (name === "edit_file") {
|
|
717
|
-
const search = typeof input.search === "string" ? input.search : "";
|
|
718
|
-
const replace = typeof input.replace === "string" ? input.replace : "";
|
|
719
|
-
if (search === "")
|
|
720
|
-
return null;
|
|
721
|
-
return editFileDiff(oldContent ?? "", search, replace);
|
|
722
|
-
}
|
|
723
|
-
const content = typeof input.content === "string" ? input.content : "";
|
|
724
|
-
return writeFileDiff(oldContent, content);
|
|
725
|
-
}
|
|
726
|
-
catch {
|
|
727
|
-
return null; // never let the diff break the approval
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
|
|
731
|
-
let last;
|
|
732
|
-
let usage = { in: null, out: null, cache: null, known: false };
|
|
733
|
-
// v3 §02: the recap line derives ENTIRELY from the local event stream
|
|
734
|
-
// (zero tokens) — wall seconds, tool/edit counts, usage, ctx left.
|
|
735
|
-
const turnStart = Date.now();
|
|
736
|
-
let toolCount = 0;
|
|
737
|
-
let editCount = 0;
|
|
738
|
-
try {
|
|
739
|
-
for await (const ev of run) {
|
|
740
|
-
last = ev;
|
|
741
|
-
// v2a (双回显): the interactive echo was already rendered by the
|
|
742
|
-
// input source — rendering the event again is the double echo.
|
|
743
|
-
// v2b: DOCKED — the echo lives in the input row (H), NOT the body;
|
|
744
|
-
// the body render is the ONLY visible copy of the sent line.
|
|
745
|
-
if (ev.type === "user_input" &&
|
|
746
|
-
liveInput !== null &&
|
|
747
|
-
liveInput.current === (typeof ev.content === "string" ? ev.content : "") &&
|
|
748
|
-
process.stdin.isTTY &&
|
|
749
|
-
!dock.active) {
|
|
750
|
-
continue;
|
|
751
|
-
}
|
|
752
|
-
// v2d: EVERY event only mutates a cell — the Body is the single
|
|
753
|
-
// writer of the scroll region, so interleaving is impossible by
|
|
754
|
-
// construction (ADR-0040).
|
|
755
|
-
switch (ev.type) {
|
|
756
|
-
case "user_input":
|
|
757
|
-
body.userLine(typeof ev.content === "string" ? ev.content : "");
|
|
758
|
-
break;
|
|
759
|
-
case "thinking":
|
|
760
|
-
body.thinkingAppend(ev.text);
|
|
761
|
-
break;
|
|
762
|
-
case "tool_call_end":
|
|
763
|
-
toolCount += 1;
|
|
764
|
-
if (ev.name === "edit_file")
|
|
765
|
-
editCount += 1;
|
|
766
|
-
body.toolStart(ev.name, ev.callId, ev.input ?? {});
|
|
767
|
-
break;
|
|
768
|
-
case "tool_execution_started":
|
|
769
|
-
body.toolRunning(ev.callId);
|
|
770
|
-
break;
|
|
771
|
-
case "tool_execution_succeeded":
|
|
772
|
-
body.toolSucceeded(ev.callId);
|
|
773
|
-
break;
|
|
774
|
-
case "tool_execution_failed":
|
|
775
|
-
body.toolFailed(ev.callId, ev.error);
|
|
776
|
-
break;
|
|
777
|
-
case "tool_result": {
|
|
778
|
-
const text = typeof ev.content === "string" ? ev.content : "";
|
|
779
|
-
body.toolResult(ev.callId, { content: text, isError: ev.isError });
|
|
780
|
-
break;
|
|
781
|
-
}
|
|
782
|
-
case "text_delta":
|
|
783
|
-
body.textAppend(ev.text);
|
|
784
|
-
break;
|
|
785
|
-
case "text_end":
|
|
786
|
-
body.textEnd();
|
|
787
|
-
break;
|
|
788
|
-
case "usage":
|
|
789
|
-
usage = { in: ev.inputTokens, out: ev.outputTokens, cache: ev.cacheRead, known: ev.known };
|
|
790
|
-
statusCb?.(usage, estimateCtxRatio(session));
|
|
791
|
-
break;
|
|
792
|
-
case "uncertain_pending":
|
|
793
|
-
// 裁决 #12 (ADR-0038): the ⚠ line is pure INFORMATION now — the
|
|
794
|
-
// approval chain guards retries, and the human question belongs
|
|
795
|
-
// only to the crash window's recovery flow (resolveUncertains).
|
|
796
|
-
body.notice(`⚠ ${escapeTerminal(ev.name)} FAILED — the side effect may have applied. ${escapeTerminal(ev.error)}`);
|
|
797
|
-
break;
|
|
798
|
-
case "permission_requested": {
|
|
799
|
-
// v2d: the ToolCell shows the ⏸ badge; the question takes over
|
|
800
|
-
// the dock status position; the answer lands at the input line.
|
|
801
|
-
// v2e: the mini-diff for edit/write at the approval moment —
|
|
802
|
-
// the human sees the change BEFORE deciding (auto-allowed tools
|
|
803
|
-
// skip the diff: nobody is looking).
|
|
804
|
-
const name = ev.name;
|
|
805
|
-
body.toolApproval(ev.callId, approvalDiff(name, ev.input ?? {}));
|
|
806
|
-
const decisionId = ev.decisionId;
|
|
807
|
-
const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
|
|
808
|
-
if (answer === CANCELLED) {
|
|
809
|
-
// 十: a cancellation is a CONSERVATIVE denial, explicitly
|
|
810
|
-
// distinguished from the user typing "n".
|
|
811
|
-
body.notice("[approval cancelled — treated as a denial]");
|
|
812
|
-
await session.approve(decisionId, false);
|
|
813
|
-
continue;
|
|
814
|
-
}
|
|
815
|
-
await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
|
|
816
|
-
break;
|
|
817
|
-
}
|
|
818
|
-
case "terminal": {
|
|
819
|
-
// v3 §02: the run's recap line REPLACES the old "done" label
|
|
820
|
-
// + status line — one local line, derived from this run's
|
|
821
|
-
// events (zero tokens). The dock's status bar still paints.
|
|
822
|
-
statusCb?.(usage, estimateCtxRatio(session));
|
|
823
|
-
const ratio = estimateCtxRatio(session);
|
|
824
|
-
bodyLog(renderRecap({
|
|
825
|
-
seconds: Math.round((Date.now() - turnStart) / 1000),
|
|
826
|
-
tools: toolCount,
|
|
827
|
-
edits: editCount,
|
|
828
|
-
usage,
|
|
829
|
-
ctxLeftPct: Number.isFinite(ratio) ? (1 - ratio) * 100 : null,
|
|
830
|
-
}));
|
|
831
|
-
break;
|
|
832
|
-
}
|
|
833
|
-
default: {
|
|
834
|
-
// Events without a cell (stop, …) — the generic render, byte-
|
|
835
|
-
// preserved for the pipe path.
|
|
836
|
-
const rendered = renderEvent(ev, false, canonicalTargetPath);
|
|
837
|
-
if (rendered.text !== "") {
|
|
838
|
-
body.raw(rendered.text.replace(/\n$/, "").split("\n"));
|
|
839
|
-
}
|
|
840
|
-
break;
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
body.thinkingEnd(); // a trailing thinking block folds at the run's end
|
|
845
|
-
}
|
|
846
|
-
finally {
|
|
847
|
-
}
|
|
848
|
-
return last;
|
|
849
|
-
}
|
|
850
|
-
/** 十: a faux-mode run whose scripted turns are exhausted must NOT print a
|
|
851
|
-
* provider error and exit 0 — the honest outcome is a loud message and a
|
|
852
|
-
* non-zero exit. Thrown as a CONTROLLED exception (never process.exit):
|
|
853
|
-
* the REPL closes, the error propagates through main's finally (so
|
|
854
|
-
* agent.close() runs and no lock is left behind), and main's catch sets
|
|
855
|
-
* the exit code. Only the exhaustion signature (the empty stream after
|
|
856
|
-
* the declared turns) triggers the script-specific message; any other
|
|
857
|
-
* error terminal still exits non-zero. */
|
|
858
|
-
class FauxExhaustionError extends Error {
|
|
859
|
-
constructor(message) {
|
|
860
|
-
super(message);
|
|
861
|
-
this.name = "FauxExhaustionError";
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
function failOnFauxExhaustion(last, faux, input) {
|
|
865
|
-
if (!faux)
|
|
866
|
-
return;
|
|
867
|
-
if (last?.type !== "terminal" || last.outcome.kind !== "error")
|
|
868
|
-
return;
|
|
869
|
-
const message = last.outcome.error.message;
|
|
870
|
-
input?.close(); // the REPL must not stay open waiting for a line
|
|
871
|
-
throw new FauxExhaustionError(message.startsWith("provider stream ended without a stop event")
|
|
872
|
-
? "[faux mode] the scripted demo turns are exhausted — set ANTHROPIC_API_KEY or OPENAI_API_KEY for a real model"
|
|
873
|
-
: `[faux mode] the scripted model failed: ${escapeTerminal(message.slice(0, 200))}`);
|
|
874
|
-
}
|
|
875
|
-
/** Interactive REPL: stream events, pause for approvals, Ctrl+C aborts. */
|
|
876
|
-
async function chat(session, faux, input) {
|
|
877
|
-
let currentRun = null;
|
|
878
|
-
let cancelled = false;
|
|
879
|
-
const turn = (text) => new Promise((resolve, reject) => {
|
|
880
|
-
queued = Math.max(0, queued - 1); // a queued turn starts
|
|
881
|
-
// v2a: the echo filter compares the user_input event against THIS
|
|
882
|
-
// turn's own input — lines that arrive ahead of their turn (piped
|
|
883
|
-
// bursts, queued replays) must not overwrite the reference.
|
|
884
|
-
liveInput.current = text;
|
|
885
|
-
const run = session.run(text);
|
|
886
|
-
currentRun = run;
|
|
887
|
-
turnNo += 1;
|
|
888
|
-
const myTurn = turnNo;
|
|
889
|
-
// v3 §03: the running state owns the status bar — the glyph
|
|
890
|
-
// rotates every 200ms; the idle state returns after the run.
|
|
891
|
-
runStart = Date.now();
|
|
892
|
-
runUsage = { in: null, out: null, cache: null, known: false };
|
|
893
|
-
const stopSpinner = startStatusSpinner((g) => {
|
|
894
|
-
runGlyph = g;
|
|
895
|
-
paintRunning();
|
|
896
|
-
});
|
|
897
|
-
(async () => {
|
|
898
|
-
let last;
|
|
899
|
-
try {
|
|
900
|
-
last = await consumeRun(session, run, input, myTurn, faux, liveInput, statusCb);
|
|
901
|
-
stopSpinner();
|
|
902
|
-
paintIdle();
|
|
903
|
-
currentRun = null;
|
|
904
|
-
// 八: a faux script that ran out of declared turns exits
|
|
905
|
-
// loudly with a non-zero status — never a silent status 0.
|
|
906
|
-
// 第四轮(对抗): the exhaustion is a CONTROLLED rejection of
|
|
907
|
-
// this turn's promise — it propagates through the chain to
|
|
908
|
-
// chat to main's finally/catch, never an orphaned
|
|
909
|
-
// unhandled rejection from the IIFE.
|
|
910
|
-
failOnFauxExhaustion(last, faux, input);
|
|
911
|
-
// 八: after EVERY turn the prompt is re-armed — the human
|
|
912
|
-
// never types blind after the first turn.
|
|
913
|
-
input.prompt();
|
|
914
|
-
resolve();
|
|
915
|
-
}
|
|
916
|
-
catch (err) {
|
|
917
|
-
// A run failure must not freeze the REPL (review finding
|
|
918
|
-
// 11): surface it and re-arm the prompt.
|
|
919
|
-
if (err instanceof FauxExhaustionError) {
|
|
920
|
-
currentRun = null;
|
|
921
|
-
reject(err);
|
|
922
|
-
return;
|
|
923
|
-
}
|
|
924
|
-
console.error(`\n[run failed] ${err instanceof Error ? err.message : String(err)}\n`);
|
|
925
|
-
currentRun = null;
|
|
926
|
-
input.prompt();
|
|
927
|
-
resolve();
|
|
928
|
-
}
|
|
929
|
-
})();
|
|
930
|
-
});
|
|
931
|
-
input.onSigint(() => {
|
|
932
|
-
if (currentRun) {
|
|
933
|
-
// 八: Ctrl+C cancels BOTH the pending question (if one is
|
|
934
|
-
// awaiting a line) and the run — the run then writes its unique
|
|
935
|
-
// aborted terminal, which the consumer keeps consuming.
|
|
936
|
-
console.log("\n[aborting run]");
|
|
937
|
-
pendingAsk?.();
|
|
938
|
-
currentRun.abort();
|
|
939
|
-
}
|
|
940
|
-
else if (pendingAsk !== null) {
|
|
941
|
-
pendingAsk?.(); // a startup/trust question — cancel it
|
|
942
|
-
}
|
|
943
|
-
else if (input.line() === "") {
|
|
944
|
-
cancelled = true;
|
|
945
|
-
console.log("\n[exit requested]");
|
|
946
|
-
input.close();
|
|
947
|
-
}
|
|
948
|
-
else {
|
|
949
|
-
input.clearLine(); // v2c: Ctrl+C on a non-empty line clears it
|
|
950
|
-
}
|
|
951
|
-
});
|
|
952
|
-
input.onEot(() => {
|
|
953
|
-
if (!currentRun && pendingAsk === null && input.line() === "") {
|
|
954
|
-
cancelled = true;
|
|
955
|
-
console.log("\n[exit requested]");
|
|
956
|
-
input.close();
|
|
957
|
-
}
|
|
958
|
-
});
|
|
959
|
-
input.onEscape(() => {
|
|
960
|
-
if (currentRun) {
|
|
961
|
-
console.log("\n[aborting run]");
|
|
962
|
-
pendingAsk?.();
|
|
963
|
-
currentRun.abort();
|
|
964
|
-
}
|
|
965
|
-
});
|
|
966
|
-
// 第五轮(P1-11): the PERSISTENT line listener is installed BEFORE the
|
|
967
|
-
// startup recovery — a cancelled question's re-emitted "line" needs a
|
|
968
|
-
// listener from the very first instant, or the input is silently lost.
|
|
969
|
-
// Turns are SERIALIZED on a chain — piped lines arrive faster than
|
|
970
|
-
// turns complete, and concurrent runs are forbidden. Lines that arrive
|
|
971
|
-
// while the recovery is still running are QUEUED and replayed once the
|
|
972
|
-
// REPL is ready (they are never dropped).
|
|
973
|
-
let chain = Promise.resolve();
|
|
974
|
-
let replReady = false;
|
|
975
|
-
const queuedLines = [];
|
|
976
|
-
// B 区: user-turn counter for the status line. /last and /think read
|
|
977
|
-
// the body (the ToolCell / ThinkingCell final states).
|
|
978
|
-
let turnNo = 0;
|
|
979
|
-
// v2a: the last line THIS process's readline consumed — the double-echo
|
|
980
|
-
// filter (see consumeRun). Only interactive chat sets it.
|
|
981
|
-
const liveInput = { current: null };
|
|
982
|
-
// v2c: turns submitted while another runs are QUEUED on the chain — the
|
|
983
|
-
// live count rides the status bar (+N queued).
|
|
984
|
-
let queued = 0;
|
|
985
|
-
// v2b: the live status bar (docked only). Modes: /mode switches repaint
|
|
986
|
-
// it immediately through paintStatus (the last turn stats are kept).
|
|
987
|
-
// v3 §03: the status bar has TWO states. Idle: the mode is ALWAYS
|
|
988
|
-
// shown (default included) with the /mode hint. Running: the working
|
|
989
|
-
// glyph (▖▘▝▗ — the spinner drives it) + wall seconds + ↓ out tokens
|
|
990
|
-
// + the interrupt hint. ctx left is the live estimate everywhere.
|
|
991
|
-
let runUsage = { in: null, out: null, cache: null, known: false };
|
|
992
|
-
let runGlyph = "▖";
|
|
993
|
-
let runStart = Date.now();
|
|
994
|
-
const paintRunning = () => {
|
|
995
|
-
if (!dock.active)
|
|
996
|
-
return;
|
|
997
|
-
const ratio = estimateCtxRatio(session);
|
|
998
|
-
const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
|
|
999
|
-
const out = runUsage.out !== null ? ` ↓ ${kUnit(runUsage.out)} tokens` : "";
|
|
1000
|
-
dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
|
|
1001
|
-
};
|
|
1002
|
-
const paintIdle = () => {
|
|
1003
|
-
if (!dock.active)
|
|
1004
|
-
return;
|
|
1005
|
-
const ratio = estimateCtxRatio(session);
|
|
1006
|
-
const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
|
|
1007
|
-
dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
|
|
1008
|
-
};
|
|
1009
|
-
const statusCb = (u, ctx) => {
|
|
1010
|
-
runUsage = u;
|
|
1011
|
-
paintRunning();
|
|
1012
|
-
};
|
|
1013
|
-
// The ONE dispatcher: slash commands, exit, and turns. The recovery
|
|
1014
|
-
// replay routes through it too — a queued "/last" must never become a
|
|
1015
|
-
// user turn (v2c: the rl lives in main, so lines arrive earlier and
|
|
1016
|
-
// the queue is the common path).
|
|
1017
|
-
const dispatch = (line) => {
|
|
1018
|
-
const trimmed = line.trim();
|
|
1019
|
-
if (trimmed === "/help") {
|
|
1020
|
-
// Prints the available commands with one-line descriptions.
|
|
1021
|
-
// v2a: the command names are the blue identity accent.
|
|
1022
|
-
const p = palette();
|
|
1023
|
-
const cmd = (name, desc) => `${p.blue}${name}${p.reset} ${desc}`;
|
|
1024
|
-
chain = chain.then(async () => {
|
|
1025
|
-
bodyLog(cmd("/help", "print this list of commands"));
|
|
1026
|
-
bodyLog(cmd("/think", "show the last full thinking block"));
|
|
1027
|
-
bodyLog(cmd("/last", "show the most recent tool call's input and output"));
|
|
1028
|
-
bodyLog(cmd("/status", "show session id, event count, and context estimate"));
|
|
1029
|
-
bodyLog(cmd("/mode", "show the approval tier; /mode <name> switches (manual/default/accept-edits/plan/bypass)"));
|
|
1030
|
-
bodyLog(cmd("/compact", "summarize the older conversation to free context"));
|
|
1031
|
-
bodyLog(cmd("exit", "leave the session"));
|
|
1032
|
-
input.prompt();
|
|
1033
|
-
});
|
|
1034
|
-
return;
|
|
1035
|
-
}
|
|
1036
|
-
if (trimmed === "/think") {
|
|
1037
|
-
// v2b/v2d: print the last COMPLETE thinking block — the body holds
|
|
1038
|
-
// it (the ThinkingCell's fold closes at the block's end).
|
|
1039
|
-
chain = chain.then(async () => {
|
|
1040
|
-
const t = body.lastThinking();
|
|
1041
|
-
if (t === null) {
|
|
1042
|
-
bodyLog("[no thinking yet]");
|
|
1043
|
-
}
|
|
1044
|
-
else {
|
|
1045
|
-
bodyLog(escapeTerminal(t));
|
|
1046
|
-
}
|
|
1047
|
-
input.prompt();
|
|
1048
|
-
});
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
if (trimmed === "/last") {
|
|
1052
|
-
// B 区/v2d: print the FULL input/output of the most recent tool
|
|
1053
|
-
// call — the body holds it (the ToolCell's final state). Runs on
|
|
1054
|
-
// the chain: after any in-flight turn completes.
|
|
1055
|
-
chain = chain.then(async () => {
|
|
1056
|
-
const tool = body.lastTool();
|
|
1057
|
-
if (tool === null) {
|
|
1058
|
-
bodyLog("[no tool call yet]");
|
|
1059
|
-
}
|
|
1060
|
-
else {
|
|
1061
|
-
bodyLog(`--- ${tool.name} input ---`);
|
|
1062
|
-
bodyLog(escapeTerminal(JSON.stringify(tool.input, null, 2)));
|
|
1063
|
-
bodyLog(`--- ${tool.name} output${tool.result.isError ? " (error)" : ""} ---`);
|
|
1064
|
-
bodyLog(escapeTerminal(tool.result.content));
|
|
1065
|
-
}
|
|
1066
|
-
input.prompt();
|
|
1067
|
-
});
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
if (trimmed === "/status") {
|
|
1071
|
-
// B 区: session id, durable event count, and the ~ context
|
|
1072
|
-
// estimate — all read straight from the live session, nothing
|
|
1073
|
-
// stored separately. Runs on the chain after any in-flight turn.
|
|
1074
|
-
chain = chain.then(async () => {
|
|
1075
|
-
const ctxRatio = estimateCtxRatio(session);
|
|
1076
|
-
const ctx = Number.isFinite(ctxRatio) ? `~${Math.round(ctxRatio * 100)}%` : "~?";
|
|
1077
|
-
bodyLog(`session ${session.id}`);
|
|
1078
|
-
bodyLog(`${session.log.all.length} events`);
|
|
1079
|
-
bodyLog(`ctx ${ctx}`);
|
|
1080
|
-
input.prompt();
|
|
1081
|
-
});
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
if (trimmed === "/mode" || trimmed.startsWith("/mode ")) {
|
|
1085
|
-
// Modes: /mode alone prints the current tier + the list;
|
|
1086
|
-
// /mode <name> switches — the notice cell leaves the audit
|
|
1087
|
-
// line in the body, the status bar repaints at once.
|
|
1088
|
-
chain = chain.then(async () => {
|
|
1089
|
-
const m = MODES.find((x) => x === trimmed.slice(5).trim());
|
|
1090
|
-
if (trimmed.slice(5).trim() === "") {
|
|
1091
|
-
bodyLog(`mode ${getMode()}`);
|
|
1092
|
-
bodyLog(`tiers: ${MODES.join(" ")}`);
|
|
1093
|
-
}
|
|
1094
|
-
else if (m === undefined) {
|
|
1095
|
-
bodyLog(`no such mode: ${trimmed.slice(5).trim()}`);
|
|
1096
|
-
bodyLog(`tiers: ${MODES.join(" ")}`);
|
|
1097
|
-
}
|
|
1098
|
-
else {
|
|
1099
|
-
setMode(m);
|
|
1100
|
-
body.notice(`mode → ${m}`);
|
|
1101
|
-
paintIdle();
|
|
1102
|
-
}
|
|
1103
|
-
input.prompt();
|
|
1104
|
-
});
|
|
1105
|
-
return;
|
|
1106
|
-
}
|
|
1107
|
-
if (trimmed === "/compact") {
|
|
1108
|
-
// /compact (ADR-0044): the older conversation becomes one
|
|
1109
|
-
// model summary — an OFF-LOOP call through the session's own
|
|
1110
|
-
// adapter, so it must never race a running turn: refused
|
|
1111
|
-
// mid-run, with a hint to wait for the turn to end.
|
|
1112
|
-
if (currentRun !== null) {
|
|
1113
|
-
body.notice("[/compact] a turn is running — wait for it to finish");
|
|
1114
|
-
return;
|
|
1115
|
-
}
|
|
1116
|
-
chain = chain.then(async () => {
|
|
1117
|
-
try {
|
|
1118
|
-
const result = await session.summarize();
|
|
1119
|
-
if (result === null) {
|
|
1120
|
-
body.notice("[/compact] nothing to compact — fewer than 5 rounds yet");
|
|
1121
|
-
}
|
|
1122
|
-
else {
|
|
1123
|
-
body.notice(`[/compact] saved ~${result.savedTokens.toLocaleString("en-US")} tokens`);
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
catch (err) {
|
|
1127
|
-
// Honest failure: nothing was persisted, the session
|
|
1128
|
-
// is unchanged (ADR-0044 crash semantics).
|
|
1129
|
-
body.notice(`[/compact] failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1130
|
-
}
|
|
1131
|
-
input.prompt();
|
|
1132
|
-
});
|
|
1133
|
-
return;
|
|
1134
|
-
}
|
|
1135
|
-
if (trimmed === "exit" || trimmed === "") {
|
|
1136
|
-
input.close();
|
|
1137
|
-
return;
|
|
1138
|
-
}
|
|
1139
|
-
// v2c: a turn submitted while another runs waits on the chain — the
|
|
1140
|
-
// live count rides the status bar (+N queued).
|
|
1141
|
-
queued += 1;
|
|
1142
|
-
chain = chain.then(() => turn(line));
|
|
1143
|
-
};
|
|
1144
|
-
input.onLine((line) => {
|
|
1145
|
-
if (!replReady) {
|
|
1146
|
-
queuedLines.push(line);
|
|
1147
|
-
return;
|
|
1148
|
-
}
|
|
1149
|
-
dispatch(line);
|
|
1150
|
-
});
|
|
1151
|
-
// Recovery first: a session with a dangling pause or uncertain
|
|
1152
|
-
// executions must resolve them BEFORE the REPL accepts new turns —
|
|
1153
|
-
// otherwise the interrupted run dangles while a new one starts.
|
|
1154
|
-
// 八: the startup resume is bound to currentRun — Ctrl+C during it
|
|
1155
|
-
// aborts the recovery, exactly like the interactive turns.
|
|
1156
|
-
await resolveUncertains(session, input, () => cancelled);
|
|
1157
|
-
if (!cancelled) {
|
|
1158
|
-
const recoveryRun = session.resume();
|
|
1159
|
-
currentRun = recoveryRun;
|
|
1160
|
-
turnNo += 1;
|
|
1161
|
-
const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
|
|
1162
|
-
currentRun = null;
|
|
1163
|
-
failOnFauxExhaustion(last, faux, input);
|
|
1164
|
-
}
|
|
1165
|
-
if (cancelled) {
|
|
1166
|
-
input.close();
|
|
1167
|
-
await input.closed;
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
// The REPL is ready: replay anything that arrived during recovery.
|
|
1171
|
-
replReady = true;
|
|
1172
|
-
// v2c: dispatch SYNCHRONOUSLY — each call appends its segment to the
|
|
1173
|
-
// chain variable; the final `await chain` then covers every replayed
|
|
1174
|
-
// turn. A chain.then(() => dispatch()) indirection would capture the
|
|
1175
|
-
// chain BEFORE the appends and the replayed turns would never be
|
|
1176
|
-
// awaited (the F-group regression).
|
|
1177
|
-
for (const line of queuedLines) {
|
|
1178
|
-
dispatch(line);
|
|
1179
|
-
}
|
|
1180
|
-
queuedLines.length = 0;
|
|
1181
|
-
input.prompt();
|
|
1182
|
-
await input.closed;
|
|
1183
|
-
await chain; // never exit while a turn is in flight
|
|
1184
|
-
}
|
|
1185
|
-
/**
|
|
1186
|
-
* Resume = the RECOVERY flow (Area 2/7): uncertain executions are decided,
|
|
1187
|
-
* the interrupted run is continued via session.resume() — never faked with
|
|
1188
|
-
* a new prompt. An optional prompt afterwards starts a genuinely new turn.
|
|
1189
|
-
* E 组: SIGINT aborts the run being resumed; every exit path closes the
|
|
1190
|
-
* session store so no lock is left behind.
|
|
1191
|
-
*/
|
|
1192
|
-
async function resume(session, prompt, faux, input) {
|
|
1193
|
-
let currentRun = null;
|
|
1194
|
-
let cancelled = false;
|
|
1195
|
-
let turnNo = 0;
|
|
1196
|
-
// v3 §03: the two-state status bar (see chat — same shapes).
|
|
1197
|
-
let runUsage = { in: null, out: null, cache: null, known: false };
|
|
1198
|
-
let runGlyph = "▖";
|
|
1199
|
-
let runStart = Date.now();
|
|
1200
|
-
const statusCb = (u, ctx) => {
|
|
1201
|
-
runUsage = u;
|
|
1202
|
-
if (!dock.active)
|
|
1203
|
-
return;
|
|
1204
|
-
const pct = Number.isFinite(ctx) ? Math.round((1 - ctx) * 100) : null;
|
|
1205
|
-
const out = runUsage.out !== null ? ` ↓ ${kUnit(runUsage.out)} tokens` : "";
|
|
1206
|
-
dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
|
|
1207
|
-
};
|
|
1208
|
-
const paintIdle = () => {
|
|
1209
|
-
if (!dock.active)
|
|
1210
|
-
return;
|
|
1211
|
-
const ratio = estimateCtxRatio(session);
|
|
1212
|
-
const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
|
|
1213
|
-
dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
|
|
1214
|
-
};
|
|
1215
|
-
const withRun = async (run) => {
|
|
1216
|
-
currentRun = run;
|
|
1217
|
-
runStart = Date.now();
|
|
1218
|
-
runUsage = { in: null, out: null, cache: null, known: false };
|
|
1219
|
-
const stopSpinner = startStatusSpinner((g) => {
|
|
1220
|
-
runGlyph = g;
|
|
1221
|
-
statusCb(runUsage, estimateCtxRatio(session));
|
|
1222
|
-
});
|
|
1223
|
-
try {
|
|
1224
|
-
turnNo += 1;
|
|
1225
|
-
const last = await consumeRun(session, run, input, turnNo, faux, null, statusCb);
|
|
1226
|
-
failOnFauxExhaustion(last, faux, input);
|
|
1227
|
-
}
|
|
1228
|
-
finally {
|
|
1229
|
-
stopSpinner();
|
|
1230
|
-
paintIdle();
|
|
1231
|
-
currentRun = null;
|
|
1232
|
-
}
|
|
1233
|
-
};
|
|
1234
|
-
input.onSigint(() => {
|
|
1235
|
-
if (currentRun) {
|
|
1236
|
-
// 八: Ctrl+C cancels the pending question AND the run.
|
|
1237
|
-
console.log("\n[aborting run]");
|
|
1238
|
-
pendingAsk?.();
|
|
1239
|
-
currentRun.abort();
|
|
1240
|
-
}
|
|
1241
|
-
else if (!cancelled) {
|
|
1242
|
-
// 第四轮(对抗): also unblock a pending startup question — the
|
|
1243
|
-
// readline close alone would leave ask() hanging forever.
|
|
1244
|
-
// 第五轮(P2-2): the cancellation is recorded so the recovery is
|
|
1245
|
-
// NOT started afterwards — Ctrl+C exits cleanly.
|
|
1246
|
-
cancelled = true;
|
|
1247
|
-
console.log("\n[exit requested]");
|
|
1248
|
-
pendingAsk?.();
|
|
1249
|
-
input.close();
|
|
1250
|
-
}
|
|
1251
|
-
});
|
|
1252
|
-
input.onEot(() => {
|
|
1253
|
-
if (!currentRun && !cancelled && input.line() === "") {
|
|
1254
|
-
cancelled = true;
|
|
1255
|
-
console.log("\n[exit requested]");
|
|
1256
|
-
input.close();
|
|
1257
|
-
}
|
|
1258
|
-
});
|
|
1259
|
-
input.onEscape(() => {
|
|
1260
|
-
if (currentRun) {
|
|
1261
|
-
console.log("\n[aborting run]");
|
|
1262
|
-
pendingAsk?.();
|
|
1263
|
-
currentRun.abort();
|
|
1264
|
-
}
|
|
1265
|
-
});
|
|
1266
|
-
try {
|
|
1267
|
-
await resolveUncertains(session, input, () => cancelled);
|
|
1268
|
-
if (!cancelled) {
|
|
1269
|
-
await withRun(session.resume());
|
|
1270
|
-
if (prompt !== undefined && prompt !== "") {
|
|
1271
|
-
await withRun(session.run(prompt));
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
finally {
|
|
1276
|
-
input.close();
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
325
|
async function main() {
|
|
1280
326
|
// Modes: --mode <name> wins over KISO_MODE — both applied before the
|
|
1281
327
|
// first makeAgent (the tier extensions read `current` live). The flag
|
|
@@ -1306,13 +352,13 @@ async function main() {
|
|
|
1306
352
|
const input = makeLineInput();
|
|
1307
353
|
// v2d: the body renderer — active only where the dock is (a color
|
|
1308
354
|
// TTY with a real size); pipes run it in passthrough, byte-for-byte.
|
|
1309
|
-
|
|
355
|
+
setBody(new Body({
|
|
1310
356
|
active: () => process.stdin.isTTY && palette().blue !== "" && (process.stdout.rows ?? 0) >= 4,
|
|
1311
357
|
height: () => process.stdout.rows ?? 24,
|
|
1312
358
|
width: () => process.stdout.columns ?? 80,
|
|
1313
359
|
editCol: () => dock.editCol(),
|
|
1314
360
|
onDock: () => dock.redraw(), // v2d-B: the freeze scrolls the dock up — re-pin it
|
|
1315
|
-
});
|
|
361
|
+
}));
|
|
1316
362
|
try {
|
|
1317
363
|
switch (command) {
|
|
1318
364
|
case "chat": {
|
|
@@ -1326,7 +372,7 @@ async function main() {
|
|
|
1326
372
|
const session = await agent.session({ id });
|
|
1327
373
|
bodyLog(`session ${id}\n`);
|
|
1328
374
|
extensionsBanner();
|
|
1329
|
-
await chat(session, faux, input);
|
|
375
|
+
await chat(session, faux, input, autoCompactFromEnv());
|
|
1330
376
|
break;
|
|
1331
377
|
}
|
|
1332
378
|
case "resume": {
|
|
@@ -1371,7 +417,7 @@ async function main() {
|
|
|
1371
417
|
const session = await agent.session({ id });
|
|
1372
418
|
bodyLog(`session ${id}\n`);
|
|
1373
419
|
extensionsBanner();
|
|
1374
|
-
await chat(session, faux, input);
|
|
420
|
+
await chat(session, faux, input, autoCompactFromEnv());
|
|
1375
421
|
break;
|
|
1376
422
|
}
|
|
1377
423
|
}
|