@vincemakes/kiso-code 0.1.19 → 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/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 { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
25
+ import { readFileSync, rmSync } from "node:fs";
19
26
  import { createInterface } from "node:readline";
20
- import { Body } from "@vincemakes/kiso-tui";
21
- import { editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
22
- import { MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
23
- import { Editor, PROMPT as EDITOR_PROMPT } from "@vincemakes/kiso-tui";
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 { canonicalTargetPath, createCodingTools } from "@vincemakes/kiso-tools-node";
30
- import { escapeTerminal, foldResult, foldThinking, palette, renderEvent, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, bannerLines, kUnit, renderRecap, truncateRow, } from "@vincemakes/kiso-tui";
31
- import { Dock } from "@vincemakes/kiso-tui";
32
- /** 发现#11: KISO_HOME is the ONE root — every default path derives from
33
- * it (sessions, trust, extensions, mcp config, skills). The dedicated
34
- * env vars (KISO_EXTENSIONS_DIR / KISO_MCP_CONFIG / KISO_SKILLS_DIR)
35
- * still override their own path; nothing hard-codes ~/.kiso anymore. */
36
- function kisoHome() {
37
- return process.env.KISO_HOME ?? join(homedir(), ".kiso");
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
- userExtensions = await loadExtensions(extensionsDir());
261
+ const user = await loadExtensions(extensionsDir());
472
262
  if (project !== null) {
473
- projectExtensions = await loadProjectExtensions(process.cwd(), userExtensions);
474
- loadedExtensions = [...userExtensions, ...projectExtensions];
263
+ const proj = await loadProjectExtensions(process.cwd(), user);
264
+ setExtensionLists(user, proj, [...user, ...proj]);
475
265
  }
476
266
  else {
477
- projectExtensions = [];
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
- agentModel = model; // v2b: the status bar shows it
288
+ setAgentModel(model); // v2b: the status bar shows it
500
289
  const definition = {
501
290
  model,
502
291
  store,
@@ -533,720 +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("exit", "leave the session"));
1031
- input.prompt();
1032
- });
1033
- return;
1034
- }
1035
- if (trimmed === "/think") {
1036
- // v2b/v2d: print the last COMPLETE thinking block — the body holds
1037
- // it (the ThinkingCell's fold closes at the block's end).
1038
- chain = chain.then(async () => {
1039
- const t = body.lastThinking();
1040
- if (t === null) {
1041
- bodyLog("[no thinking yet]");
1042
- }
1043
- else {
1044
- bodyLog(escapeTerminal(t));
1045
- }
1046
- input.prompt();
1047
- });
1048
- return;
1049
- }
1050
- if (trimmed === "/last") {
1051
- // B 区/v2d: print the FULL input/output of the most recent tool
1052
- // call — the body holds it (the ToolCell's final state). Runs on
1053
- // the chain: after any in-flight turn completes.
1054
- chain = chain.then(async () => {
1055
- const tool = body.lastTool();
1056
- if (tool === null) {
1057
- bodyLog("[no tool call yet]");
1058
- }
1059
- else {
1060
- bodyLog(`--- ${tool.name} input ---`);
1061
- bodyLog(escapeTerminal(JSON.stringify(tool.input, null, 2)));
1062
- bodyLog(`--- ${tool.name} output${tool.result.isError ? " (error)" : ""} ---`);
1063
- bodyLog(escapeTerminal(tool.result.content));
1064
- }
1065
- input.prompt();
1066
- });
1067
- return;
1068
- }
1069
- if (trimmed === "/status") {
1070
- // B 区: session id, durable event count, and the ~ context
1071
- // estimate — all read straight from the live session, nothing
1072
- // stored separately. Runs on the chain after any in-flight turn.
1073
- chain = chain.then(async () => {
1074
- const ctxRatio = estimateCtxRatio(session);
1075
- const ctx = Number.isFinite(ctxRatio) ? `~${Math.round(ctxRatio * 100)}%` : "~?";
1076
- bodyLog(`session ${session.id}`);
1077
- bodyLog(`${session.log.all.length} events`);
1078
- bodyLog(`ctx ${ctx}`);
1079
- input.prompt();
1080
- });
1081
- return;
1082
- }
1083
- if (trimmed === "/mode" || trimmed.startsWith("/mode ")) {
1084
- // Modes: /mode alone prints the current tier + the list;
1085
- // /mode <name> switches — the notice cell leaves the audit
1086
- // line in the body, the status bar repaints at once.
1087
- chain = chain.then(async () => {
1088
- const m = MODES.find((x) => x === trimmed.slice(5).trim());
1089
- if (trimmed.slice(5).trim() === "") {
1090
- bodyLog(`mode ${getMode()}`);
1091
- bodyLog(`tiers: ${MODES.join(" ")}`);
1092
- }
1093
- else if (m === undefined) {
1094
- bodyLog(`no such mode: ${trimmed.slice(5).trim()}`);
1095
- bodyLog(`tiers: ${MODES.join(" ")}`);
1096
- }
1097
- else {
1098
- setMode(m);
1099
- body.notice(`mode → ${m}`);
1100
- paintIdle();
1101
- }
1102
- input.prompt();
1103
- });
1104
- return;
1105
- }
1106
- if (trimmed === "exit" || trimmed === "") {
1107
- input.close();
1108
- return;
1109
- }
1110
- // v2c: a turn submitted while another runs waits on the chain — the
1111
- // live count rides the status bar (+N queued).
1112
- queued += 1;
1113
- chain = chain.then(() => turn(line));
1114
- };
1115
- input.onLine((line) => {
1116
- if (!replReady) {
1117
- queuedLines.push(line);
1118
- return;
1119
- }
1120
- dispatch(line);
1121
- });
1122
- // Recovery first: a session with a dangling pause or uncertain
1123
- // executions must resolve them BEFORE the REPL accepts new turns —
1124
- // otherwise the interrupted run dangles while a new one starts.
1125
- // 八: the startup resume is bound to currentRun — Ctrl+C during it
1126
- // aborts the recovery, exactly like the interactive turns.
1127
- await resolveUncertains(session, input, () => cancelled);
1128
- if (!cancelled) {
1129
- const recoveryRun = session.resume();
1130
- currentRun = recoveryRun;
1131
- turnNo += 1;
1132
- const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
1133
- currentRun = null;
1134
- failOnFauxExhaustion(last, faux, input);
1135
- }
1136
- if (cancelled) {
1137
- input.close();
1138
- await input.closed;
1139
- return;
1140
- }
1141
- // The REPL is ready: replay anything that arrived during recovery.
1142
- replReady = true;
1143
- // v2c: dispatch SYNCHRONOUSLY — each call appends its segment to the
1144
- // chain variable; the final `await chain` then covers every replayed
1145
- // turn. A chain.then(() => dispatch()) indirection would capture the
1146
- // chain BEFORE the appends and the replayed turns would never be
1147
- // awaited (the F-group regression).
1148
- for (const line of queuedLines) {
1149
- dispatch(line);
1150
- }
1151
- queuedLines.length = 0;
1152
- input.prompt();
1153
- await input.closed;
1154
- await chain; // never exit while a turn is in flight
1155
- }
1156
- /**
1157
- * Resume = the RECOVERY flow (Area 2/7): uncertain executions are decided,
1158
- * the interrupted run is continued via session.resume() — never faked with
1159
- * a new prompt. An optional prompt afterwards starts a genuinely new turn.
1160
- * E 组: SIGINT aborts the run being resumed; every exit path closes the
1161
- * session store so no lock is left behind.
1162
- */
1163
- async function resume(session, prompt, faux, input) {
1164
- let currentRun = null;
1165
- let cancelled = false;
1166
- let turnNo = 0;
1167
- // v3 §03: the two-state status bar (see chat — same shapes).
1168
- let runUsage = { in: null, out: null, cache: null, known: false };
1169
- let runGlyph = "▖";
1170
- let runStart = Date.now();
1171
- const statusCb = (u, ctx) => {
1172
- runUsage = u;
1173
- if (!dock.active)
1174
- return;
1175
- const pct = Number.isFinite(ctx) ? Math.round((1 - ctx) * 100) : null;
1176
- const out = runUsage.out !== null ? ` ↓ ${kUnit(runUsage.out)} tokens` : "";
1177
- dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
1178
- };
1179
- const paintIdle = () => {
1180
- if (!dock.active)
1181
- return;
1182
- const ratio = estimateCtxRatio(session);
1183
- const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
1184
- dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
1185
- };
1186
- const withRun = async (run) => {
1187
- currentRun = run;
1188
- runStart = Date.now();
1189
- runUsage = { in: null, out: null, cache: null, known: false };
1190
- const stopSpinner = startStatusSpinner((g) => {
1191
- runGlyph = g;
1192
- statusCb(runUsage, estimateCtxRatio(session));
1193
- });
1194
- try {
1195
- turnNo += 1;
1196
- const last = await consumeRun(session, run, input, turnNo, faux, null, statusCb);
1197
- failOnFauxExhaustion(last, faux, input);
1198
- }
1199
- finally {
1200
- stopSpinner();
1201
- paintIdle();
1202
- currentRun = null;
1203
- }
1204
- };
1205
- input.onSigint(() => {
1206
- if (currentRun) {
1207
- // 八: Ctrl+C cancels the pending question AND the run.
1208
- console.log("\n[aborting run]");
1209
- pendingAsk?.();
1210
- currentRun.abort();
1211
- }
1212
- else if (!cancelled) {
1213
- // 第四轮(对抗): also unblock a pending startup question — the
1214
- // readline close alone would leave ask() hanging forever.
1215
- // 第五轮(P2-2): the cancellation is recorded so the recovery is
1216
- // NOT started afterwards — Ctrl+C exits cleanly.
1217
- cancelled = true;
1218
- console.log("\n[exit requested]");
1219
- pendingAsk?.();
1220
- input.close();
1221
- }
1222
- });
1223
- input.onEot(() => {
1224
- if (!currentRun && !cancelled && input.line() === "") {
1225
- cancelled = true;
1226
- console.log("\n[exit requested]");
1227
- input.close();
1228
- }
1229
- });
1230
- input.onEscape(() => {
1231
- if (currentRun) {
1232
- console.log("\n[aborting run]");
1233
- pendingAsk?.();
1234
- currentRun.abort();
1235
- }
1236
- });
1237
- try {
1238
- await resolveUncertains(session, input, () => cancelled);
1239
- if (!cancelled) {
1240
- await withRun(session.resume());
1241
- if (prompt !== undefined && prompt !== "") {
1242
- await withRun(session.run(prompt));
1243
- }
1244
- }
1245
- }
1246
- finally {
1247
- input.close();
1248
- }
1249
- }
1250
325
  async function main() {
1251
326
  // Modes: --mode <name> wins over KISO_MODE — both applied before the
1252
327
  // first makeAgent (the tier extensions read `current` live). The flag
@@ -1277,13 +352,13 @@ async function main() {
1277
352
  const input = makeLineInput();
1278
353
  // v2d: the body renderer — active only where the dock is (a color
1279
354
  // TTY with a real size); pipes run it in passthrough, byte-for-byte.
1280
- body = new Body({
355
+ setBody(new Body({
1281
356
  active: () => process.stdin.isTTY && palette().blue !== "" && (process.stdout.rows ?? 0) >= 4,
1282
357
  height: () => process.stdout.rows ?? 24,
1283
358
  width: () => process.stdout.columns ?? 80,
1284
359
  editCol: () => dock.editCol(),
1285
360
  onDock: () => dock.redraw(), // v2d-B: the freeze scrolls the dock up — re-pin it
1286
- });
361
+ }));
1287
362
  try {
1288
363
  switch (command) {
1289
364
  case "chat": {
@@ -1297,7 +372,7 @@ async function main() {
1297
372
  const session = await agent.session({ id });
1298
373
  bodyLog(`session ${id}\n`);
1299
374
  extensionsBanner();
1300
- await chat(session, faux, input);
375
+ await chat(session, faux, input, autoCompactFromEnv());
1301
376
  break;
1302
377
  }
1303
378
  case "resume": {
@@ -1342,7 +417,7 @@ async function main() {
1342
417
  const session = await agent.session({ id });
1343
418
  bodyLog(`session ${id}\n`);
1344
419
  extensionsBanner();
1345
- await chat(session, faux, input);
420
+ await chat(session, faux, input, autoCompactFromEnv());
1346
421
  break;
1347
422
  }
1348
423
  }