@vincemakes/kiso-code 0.1.13 → 0.1.15

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
@@ -17,6 +17,8 @@
17
17
  */
18
18
  import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
19
19
  import { createInterface } from "node:readline";
20
+ import { Body } from "./body.js";
21
+ import { Editor, PROMPT as EDITOR_PROMPT } from "./editor.js";
20
22
  import { homedir, tmpdir } from "node:os";
21
23
  import { dirname, join } from "node:path";
22
24
  import { fileURLToPath } from "node:url";
@@ -87,25 +89,136 @@ function startupBanner() {
87
89
  return `${p.dim}${LOGO_TOP}${p.blue}${TAGLINE}${p.reset}${p.dim}${LOGO_BOTTOM} v${VERSION}${names}${p.reset}\n`;
88
90
  }
89
91
  /** v2a: the interactive prompt — blue, the identity accent. readline owns
90
- * the echo of what the user types; we own the prompt's color. */
92
+ * the echo of what the user types; we own the prompt's color. (v2c: the
93
+ * readline prompt keeps "you> " — the brick ▌ is the dock's row only;
94
+ * pipe bytes must not change.) */
91
95
  function interactivePrompt() {
92
96
  const p = palette();
93
97
  return `${p.blue}you> ${p.reset}`;
94
98
  }
99
+ /** The v2b behavior, unchanged: readline owns the line, SIGINT, and the
100
+ * prompt. Only ever constructed when stdin is NOT a TTY. The rl starts
101
+ * consuming stdin at construction (main), so 'line' events are buffered
102
+ * until chat() wires the handler — pipe input must never be dropped. */
103
+ function readlineInput(rl) {
104
+ let lineCb = null;
105
+ const pending = [];
106
+ rl.on("line", (line) => {
107
+ if (lineCb === null)
108
+ pending.push(line);
109
+ else
110
+ lineCb(line);
111
+ });
112
+ return {
113
+ onLine(cb) {
114
+ lineCb = cb;
115
+ for (const line of pending)
116
+ cb(line);
117
+ pending.length = 0;
118
+ },
119
+ onSigint(cb) {
120
+ rl.on("SIGINT", cb);
121
+ },
122
+ onEot() {
123
+ /* readline's Ctrl+D on an empty line is EOF → 'close' — the
124
+ * exit path is the close, nothing to wire here. */
125
+ },
126
+ onEscape() {
127
+ /* readline has no bare-Esc semantics — ignored. */
128
+ },
129
+ question(query, cb) {
130
+ rl.question(query, cb);
131
+ },
132
+ cancelQuestion() {
133
+ /* the rl.question stays pending; the settled branch re-emits
134
+ * the answer as a new line. */
135
+ },
136
+ emitLine(line) {
137
+ rl.emit("line", line);
138
+ },
139
+ line() {
140
+ return rl.line;
141
+ },
142
+ clearLine() {
143
+ /* readline: Ctrl+C is exit/abort only — nothing to clear. */
144
+ },
145
+ prompt() {
146
+ rl.setPrompt(interactivePrompt());
147
+ rl.prompt();
148
+ },
149
+ close() {
150
+ rl.close();
151
+ },
152
+ closed: new Promise((resolve) => rl.on("close", () => resolve())),
153
+ };
154
+ }
155
+ /** The v2c TTY path: the editor's events map 1:1 onto the interface; the
156
+ * input row renders on every state change (the CLI's onRender wiring). */
157
+ function editorInput(editor) {
158
+ return {
159
+ onLine(cb) {
160
+ editor.onLine(cb);
161
+ },
162
+ onSigint(cb) {
163
+ editor.onSigint(cb);
164
+ },
165
+ onEot(cb) {
166
+ editor.onEot(cb);
167
+ },
168
+ onEscape(cb) {
169
+ editor.onEscape(cb);
170
+ },
171
+ question(query, cb) {
172
+ editor.question(query, cb);
173
+ },
174
+ cancelQuestion() {
175
+ editor.cancelQuestion();
176
+ },
177
+ emitLine() {
178
+ /* the editor's buffer survives a cancelled question — its text
179
+ * becomes the next turn on Enter (the readline re-emit
180
+ * equivalent). */
181
+ },
182
+ line() {
183
+ return editor.line();
184
+ },
185
+ clearLine() {
186
+ editor.clearLine();
187
+ },
188
+ prompt() {
189
+ /* the editor renders on every state change — nothing to arm. */
190
+ },
191
+ close() {
192
+ editor.exit();
193
+ },
194
+ closed: editor.closed,
195
+ };
196
+ }
197
+ /** One input source per process: the raw-mode Editor on a TTY (entered
198
+ * here, bound to the dock once — the trust question, chat, and resume
199
+ * all read through it), readline elsewhere. */
200
+ function makeLineInput() {
201
+ if (process.stdin.isTTY) {
202
+ const editor = new Editor(() => (dock.active ? dock.redraw() : editor.selfRender()));
203
+ editor.enter();
204
+ const p = palette();
205
+ dock.bindInput(() => editor.dockState(), `${p.blue}${EDITOR_PROMPT}${p.reset}`);
206
+ return editorInput(editor);
207
+ }
208
+ return readlineInput(createInterface({ input: process.stdin, output: process.stdout }));
209
+ }
95
210
  /** v2b: the bottom-anchored UI — docked only on a color TTY; pipes and
96
211
  * NO_COLOR stay the v2a line mode byte-for-byte. */
97
212
  const dock = new Dock();
98
- /** v2b: body output routes through the dock when docked (the cursor into
99
- * the scroll region, then back to the input line); otherwise a plain
100
- * write pipes are byte-identical to v2a. */
101
- function bodyWrite(text) {
102
- if (dock.active)
103
- dock.writeBody(text);
104
- else
105
- process.stdout.write(text);
106
- }
213
+ /** v2d: the body renderer the ONE writer of the stdout scroll region
214
+ * (the frozen area + the active tail). Pipes run it in passthrough (the
215
+ * v2b/v2c line-mode bytes, byte-for-byte). Created in main; closed on
216
+ * every exit path. */
217
+ let body;
218
+ /** v2d: body output routes through the cell renderer — the single writer.
219
+ * bodyLog adds the trailing newline; internal newlines are preserved. */
107
220
  function bodyLog(text) {
108
- bodyWrite(`${text}\n`);
221
+ body.raw(text.split("\n"));
109
222
  }
110
223
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
111
224
  * is gone) — docked only, 200ms rotation between the request and the
@@ -171,7 +284,7 @@ function extensionsBanner() {
171
284
  * record → only a HUMAN may decide, TTY only — non-TTY refuses with one
172
285
  * stderr line. Returns the artifacts on grant, null on anything else.
173
286
  */
174
- async function resolveProjectTrust() {
287
+ async function resolveProjectTrust(input) {
175
288
  const artifacts = await projectArtifacts(process.cwd());
176
289
  if (artifacts === null)
177
290
  return null; // no .kiso artifacts — nothing to gate
@@ -188,25 +301,19 @@ async function resolveProjectTrust() {
188
301
  console.error(`[project .kiso] found ${artifacts.files.length} artifact(s) in ${artifacts.root} — not trusted, not loaded (run kiso interactively once to decide)`);
189
302
  return null;
190
303
  }
191
- const rl = createInterface({ input: process.stdin, output: process.stdout });
192
- // v2b: docked the trust question takes over the status position.
193
- dock.bindInput(() => ({ line: rl.line, cursor: rl.cursor }), interactivePrompt());
194
- try {
195
- bodyLog(`[project .kiso] ${artifacts.root}`);
196
- for (const f of artifacts.files) {
197
- bodyLog(` ${f.path} (${f.digest.slice(0, 6)})`);
198
- }
199
- const answer = await ask(rl, `trust this project's .kiso? (y/n) `);
200
- const granted = answer !== CANCELLED && answer.trim().toLowerCase().startsWith("y");
201
- recordTrust({ root: artifacts.root, digest: artifacts.digest, decision: granted ? "granted" : "refused" });
202
- if (!granted)
203
- return null;
204
- applyProjectMerges(artifacts);
205
- return artifacts;
206
- }
207
- finally {
208
- rl.close();
304
+ // v2c: the shared input (the editor on a TTY) reads the answer; the
305
+ // dock shows the question at the status position.
306
+ bodyLog(`[project .kiso] ${artifacts.root}`);
307
+ for (const f of artifacts.files) {
308
+ bodyLog(` ${f.path} (${f.digest.slice(0, 6)})`);
209
309
  }
310
+ const answer = await ask(input, `trust this project's .kiso? (y/n) `);
311
+ const granted = answer !== CANCELLED && answer.trim().toLowerCase().startsWith("y");
312
+ recordTrust({ root: artifacts.root, digest: artifacts.digest, decision: granted ? "granted" : "refused" });
313
+ if (!granted)
314
+ return null;
315
+ applyProjectMerges(artifacts);
316
+ return artifacts;
210
317
  }
211
318
  /**
212
319
  * E3 — merge the project's mcp.json and skills into the env BEFORE the
@@ -368,12 +475,12 @@ function fauxSkip(id) {
368
475
  events.filter((e) => e.type === "stop" && e.reason === "end_turn").length +
369
476
  events.filter((e) => e.type === "tool_call_end" && !results.has(e.callId)).length);
370
477
  }
371
- async function makeAgent(fauxSkipTurns = 0) {
478
+ async function makeAgent(fauxSkipTurns = 0, input) {
372
479
  const store = new SessionStore(sessionsDir());
373
480
  // E3: the project-level trust gate runs BEFORE any extension load (the
374
481
  // mcp/skills merges must be in the env when the user-level extensions
375
482
  // load). Untrusted project capability is never loaded — never silently.
376
- const project = await resolveProjectTrust();
483
+ const project = input !== undefined ? await resolveProjectTrust(input) : await resolveProjectTrust(undefined);
377
484
  // E1: the startup extension scan — a broken extension fails the process
378
485
  // LOUDLY here (loadExtensions throws), never silently.
379
486
  userExtensions = await loadExtensions(extensionsDir());
@@ -508,15 +615,20 @@ const CANCELLED = Symbol("kiso-question-cancelled");
508
615
  * question.
509
616
  */
510
617
  let pendingAsk = null;
511
- function ask(rl, question) {
618
+ function ask(input, question) {
512
619
  if (!process.stdin.isTTY) {
513
620
  console.log(`[non-interactive — no human to ask: ${question}]`);
514
621
  return Promise.resolve("");
515
622
  }
516
623
  // v2b: docked — the question takes over the status position, the
517
- // answer lands at the input line.
518
- if (dock.active)
624
+ // answer lands at the input line. v2c: a TTY without a dock (rows < 4)
625
+ // prints the question into the body — the editor cannot show it.
626
+ if (dock.active) {
519
627
  dock.showQuestion(question);
628
+ }
629
+ else {
630
+ bodyLog(question);
631
+ }
520
632
  return new Promise((resolve) => {
521
633
  let settled = false;
522
634
  pendingAsk = () => {
@@ -524,12 +636,18 @@ function ask(rl, question) {
524
636
  return;
525
637
  settled = true;
526
638
  pendingAsk = null;
639
+ input.cancelQuestion();
527
640
  resolve(CANCELLED); // the run is aborting — the question is dead
528
641
  };
529
- rl.question(dock.active ? "" : question, (answer) => {
642
+ // v2b: docked — the question reads at the input line, whose prompt
643
+ // is the same blue you> (the editor's brick row; the readline path
644
+ // passes the plain question). An empty prompt would start readline
645
+ // at column 1 while the dock renders "you> " — the typed answer
646
+ // would land on the prompt and drift (probe-confirmed).
647
+ input.question(dock.active ? interactivePrompt() : question, (answer) => {
530
648
  if (settled) {
531
649
  // The question was cancelled; this line is a NEW user turn.
532
- rl.emit("line", answer);
650
+ input.emitLine(answer);
533
651
  return;
534
652
  }
535
653
  settled = true;
@@ -560,9 +678,9 @@ function estimateCtxRatio(session) {
560
678
  return chars / 4 / contextWindowTokens();
561
679
  }
562
680
  /** Decide every uncertain execution with the human (r)erun/(a)bandon. */
563
- async function resolveUncertains(session, rl, isCancelled) {
681
+ async function resolveUncertains(session, input, isCancelled) {
564
682
  for (const uncertain of session.uncertainExecutions()) {
565
- const answer = await ask(rl, `⚠ interrupted execution: ${escapeTerminal(uncertain.name)} (${uncertain.executionId}) — did it apply? (r)erun / (a)bandon: `);
683
+ const answer = await ask(input, `⚠ interrupted execution: ${escapeTerminal(uncertain.name)} (${uncertain.executionId}) — did it apply? (r)erun / (a)bandon: `);
566
684
  if (isCancelled() || answer === CANCELLED) {
567
685
  // 十: a cancellation NEVER records a verdict — the execution
568
686
  // stays uncertain and durable; no rerun/abandoned is fabricated.
@@ -581,115 +699,104 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
581
699
  * line's form; `liveInput` (non-null only in interactive chat) carries the
582
700
  * last line THIS process's readline consumed — the double-echo filter.
583
701
  */
584
- async function consumeRun(session, run, rl, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb) {
702
+ async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
585
703
  let last;
586
- // B 区: tool_call_end → (name, input) for the summary; tool_result →
587
- // one summary line. Usage events feed the status line.
588
- const pendingCalls = new Map();
589
704
  let usage = { in: null, out: null, cache: null, known: false };
590
- // v2b: thinking blocks buffer and fold to ONE dim line at the block's
591
- // end (foldThinking); the FULL text goes to /think.
592
- let thinkingBuf = "";
593
- const flushThinking = () => {
594
- if (thinkingBuf === "")
595
- return;
596
- lastThinking.current = thinkingBuf;
597
- bodyWrite(foldThinking(thinkingBuf));
598
- thinkingBuf = "";
599
- };
600
- let thinkingOpen = false;
601
- // v2b: liveness merged into the status bar (docked); a running timer
602
- // shows "running <tool> Ns" during a tool execution.
603
- const stopSpinner = startStatusSpinner();
604
- let stopRunning = null;
605
- let firstEvent = true;
606
705
  try {
607
706
  for await (const ev of run) {
608
- if (firstEvent) {
609
- firstEvent = false;
610
- stopSpinner();
611
- }
612
707
  last = ev;
613
- // v2a (双回显): the interactive readline already echoed an input THIS
614
- // process consumed — rendering the event again is the double echo.
615
- // Replayed history (recovery/resume nobody typed) keeps the event
616
- // render. Deterministic: exact content match with the consumed line,
617
- // on a TTY (the only place an echo exists to hand over).
618
- if (ev.type === "user_input" && liveInput !== null && liveInput.current === (typeof ev.content === "string" ? ev.content : "") && process.stdin.isTTY) {
708
+ // v2a (双回显): the interactive echo was already rendered by the
709
+ // input source — rendering the event again is the double echo.
710
+ // v2b: DOCKEDthe echo lives in the input row (H), NOT the body;
711
+ // the body render is the ONLY visible copy of the sent line.
712
+ if (ev.type === "user_input" &&
713
+ liveInput !== null &&
714
+ liveInput.current === (typeof ev.content === "string" ? ev.content : "") &&
715
+ process.stdin.isTTY &&
716
+ !dock.active) {
619
717
  continue;
620
718
  }
621
- const prevThinking = thinkingOpen;
622
- thinkingOpen = ev.type === "thinking";
623
- if (prevThinking && !thinkingOpen)
624
- flushThinking();
625
- if (ev.type === "thinking") {
626
- thinkingBuf += ev.text;
627
- continue;
628
- }
629
- if (ev.type === "tool_call_end") {
630
- pendingCalls.set(ev.callId, { name: ev.name, input: ev.input ?? {} });
631
- }
632
- if (ev.type === "tool_execution_started") {
633
- stopRunning = startRunningTimer(ev.name);
634
- }
635
- if (ev.type === "tool_result") {
636
- stopRunning?.();
637
- stopRunning = null;
638
- const call = pendingCalls.get(ev.callId);
639
- pendingCalls.delete(ev.callId);
640
- if (call !== undefined) {
719
+ // v2d: EVERY event only mutates a cell — the Body is the single
720
+ // writer of the scroll region, so interleaving is impossible by
721
+ // construction (ADR-0040).
722
+ switch (ev.type) {
723
+ case "user_input":
724
+ body.userLine(typeof ev.content === "string" ? ev.content : "");
725
+ break;
726
+ case "thinking":
727
+ body.thinkingAppend(ev.text);
728
+ break;
729
+ case "tool_call_end":
730
+ body.toolStart(ev.name, ev.callId, ev.input ?? {});
731
+ break;
732
+ case "tool_execution_started":
733
+ body.toolRunning(ev.callId);
734
+ break;
735
+ case "tool_execution_succeeded":
736
+ body.toolSucceeded(ev.callId);
737
+ break;
738
+ case "tool_execution_failed":
739
+ body.toolFailed(ev.callId, ev.error);
740
+ break;
741
+ case "tool_result": {
641
742
  const text = typeof ev.content === "string" ? ev.content : "";
642
- lastToolRef.current = { name: call.name, input: call.input, result: { content: text, isError: ev.isError } };
643
- bodyLog(renderToolSummary(call.name, call.input, { content: text, isError: ev.isError }));
743
+ body.toolResult(ev.callId, { content: text, isError: ev.isError });
744
+ break;
644
745
  }
645
- }
646
- if (ev.type === "usage") {
647
- usage = { in: ev.inputTokens, out: ev.outputTokens, cache: ev.cacheRead, known: ev.known };
648
- statusCb?.(usage, estimateCtxRatio(session));
649
- }
650
- if (ev.type === "uncertain_pending") {
651
- // 裁决 #12 (ADR-0038): the ⚠ line is pure INFORMATION now — the
652
- // approval chain guards retries, and the human question belongs
653
- // only to the crash window's recovery flow (resolveUncertains).
654
- // Old logs may still carry the event; replay shows the fact.
655
- bodyLog(`\n⚠ ${escapeTerminal(ev.name)} FAILED — the side effect may have applied.\n ${escapeTerminal(ev.error)}\n`);
656
- continue;
657
- }
658
- const rendered = renderEvent(ev, prevThinking);
659
- if (rendered.prompt) {
660
- // v2b (docked): the detail scrolls into the body; the question
661
- // takes over the status position; the answer lands at the input
662
- // line. Pipes keep the v2a inline render.
663
- bodyWrite(rendered.text);
664
- const decisionId = ev.decisionId;
665
- const name = ev.name;
666
- // 八: the tool name is model text — escaped on every output path.
667
- const answer = await ask(rl, `approve ${escapeTerminal(name)}? (y/n) `);
668
- if (answer === CANCELLED) {
669
- // 十: a cancellation is a CONSERVATIVE denial, explicitly
670
- // distinguished from the user typing "n".
671
- bodyLog("[approval cancelled — treated as a denial]\n");
672
- await session.approve(decisionId, false);
673
- continue;
746
+ case "text_delta":
747
+ body.textAppend(ev.text);
748
+ break;
749
+ case "text_end":
750
+ body.textEnd();
751
+ break;
752
+ case "usage":
753
+ usage = { in: ev.inputTokens, out: ev.outputTokens, cache: ev.cacheRead, known: ev.known };
754
+ statusCb?.(usage, estimateCtxRatio(session));
755
+ break;
756
+ case "uncertain_pending":
757
+ // 裁决 #12 (ADR-0038): the ⚠ line is pure INFORMATION now — the
758
+ // approval chain guards retries, and the human question belongs
759
+ // only to the crash window's recovery flow (resolveUncertains).
760
+ body.notice(`⚠ ${escapeTerminal(ev.name)} FAILED — the side effect may have applied. ${escapeTerminal(ev.error)}`);
761
+ break;
762
+ case "permission_requested": {
763
+ // v2d: the ToolCell shows the badge; the question takes over
764
+ // the dock status position; the answer lands at the input line.
765
+ body.toolApproval(ev.callId);
766
+ const decisionId = ev.decisionId;
767
+ const name = ev.name;
768
+ const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
769
+ if (answer === CANCELLED) {
770
+ // 十: a cancellation is a CONSERVATIVE denial, explicitly
771
+ // distinguished from the user typing "n".
772
+ body.notice("[approval cancelled — treated as a denial]");
773
+ await session.approve(decisionId, false);
774
+ continue;
775
+ }
776
+ await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
777
+ break;
778
+ }
779
+ case "terminal":
780
+ statusCb?.(usage, estimateCtxRatio(session));
781
+ // v2a rhythm: the honest label (\ndone\n — the completed
782
+ // marker), the status line hugging it, then EXACTLY one blank
783
+ // line before the next prompt.
784
+ body.terminal(renderEvent(ev).text, renderStatusLine(turnNo, usage, estimateCtxRatio(session), faux) ?? "");
785
+ break;
786
+ default: {
787
+ // Events without a cell (stop, …) — the generic render, byte-
788
+ // preserved for the pipe path.
789
+ const rendered = renderEvent(ev);
790
+ if (rendered.text !== "") {
791
+ body.raw(rendered.text.replace(/\n$/, "").split("\n"));
792
+ }
793
+ break;
674
794
  }
675
- await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
676
- }
677
- else {
678
- bodyWrite(rendered.text);
679
- }
680
- if (ev.type === "terminal") {
681
- statusCb?.(usage, estimateCtxRatio(session));
682
- // v2a rhythm: the status line hugs the terminal (有什么显什么 —
683
- // null = nothing to show), then EXACTLY one blank line before
684
- // the next prompt.
685
- bodyWrite(renderTerminalGap(renderStatusLine(turnNo, usage, estimateCtxRatio(session), faux)));
686
795
  }
687
796
  }
688
- flushThinking();
797
+ body.thinkingEnd(); // a trailing thinking block folds at the run's end
689
798
  }
690
799
  finally {
691
- stopSpinner();
692
- stopRunning?.();
693
800
  }
694
801
  return last;
695
802
  }
@@ -707,35 +814,35 @@ class FauxExhaustionError extends Error {
707
814
  this.name = "FauxExhaustionError";
708
815
  }
709
816
  }
710
- function failOnFauxExhaustion(last, faux, rl) {
817
+ function failOnFauxExhaustion(last, faux, input) {
711
818
  if (!faux)
712
819
  return;
713
820
  if (last?.type !== "terminal" || last.outcome.kind !== "error")
714
821
  return;
715
822
  const message = last.outcome.error.message;
716
- rl?.close(); // the REPL must not stay open waiting for a line
823
+ input?.close(); // the REPL must not stay open waiting for a line
717
824
  throw new FauxExhaustionError(message.startsWith("provider stream ended without a stop event")
718
825
  ? "[faux mode] the scripted demo turns are exhausted — set ANTHROPIC_API_KEY or OPENAI_API_KEY for a real model"
719
826
  : `[faux mode] the scripted model failed: ${escapeTerminal(message.slice(0, 200))}`);
720
827
  }
721
828
  /** Interactive REPL: stream events, pause for approvals, Ctrl+C aborts. */
722
- async function chat(session, faux) {
723
- const rl = createInterface({ input: process.stdin, output: process.stdout });
829
+ async function chat(session, faux, input) {
724
830
  let currentRun = null;
725
831
  let cancelled = false;
726
- const turn = (input) => new Promise((resolve, reject) => {
832
+ const turn = (text) => new Promise((resolve, reject) => {
833
+ queued = Math.max(0, queued - 1); // a queued turn starts
727
834
  // v2a: the echo filter compares the user_input event against THIS
728
835
  // turn's own input — lines that arrive ahead of their turn (piped
729
836
  // bursts, queued replays) must not overwrite the reference.
730
- liveInput.current = input;
731
- const run = session.run(input);
837
+ liveInput.current = text;
838
+ const run = session.run(text);
732
839
  currentRun = run;
733
840
  turnNo += 1;
734
841
  const myTurn = turnNo;
735
842
  (async () => {
736
843
  let last;
737
844
  try {
738
- last = await consumeRun(session, run, rl, myTurn, lastToolRef, faux, liveInput, lastThinking, statusCb);
845
+ last = await consumeRun(session, run, input, myTurn, faux, liveInput, statusCb);
739
846
  currentRun = null;
740
847
  // 八: a faux script that ran out of declared turns exits
741
848
  // loudly with a non-zero status — never a silent status 0.
@@ -743,11 +850,10 @@ async function chat(session, faux) {
743
850
  // this turn's promise — it propagates through the chain to
744
851
  // chat to main's finally/catch, never an orphaned
745
852
  // unhandled rejection from the IIFE.
746
- failOnFauxExhaustion(last, faux, rl);
853
+ failOnFauxExhaustion(last, faux, input);
747
854
  // 八: after EVERY turn the prompt is re-armed — the human
748
855
  // never types blind after the first turn.
749
- rl.setPrompt(interactivePrompt());
750
- rl.prompt();
856
+ input.prompt();
751
857
  resolve();
752
858
  }
753
859
  catch (err) {
@@ -760,13 +866,12 @@ async function chat(session, faux) {
760
866
  }
761
867
  console.error(`\n[run failed] ${err instanceof Error ? err.message : String(err)}\n`);
762
868
  currentRun = null;
763
- rl.setPrompt(interactivePrompt());
764
- rl.prompt();
869
+ input.prompt();
765
870
  resolve();
766
871
  }
767
872
  })();
768
873
  });
769
- rl.on("SIGINT", () => {
874
+ input.onSigint(() => {
770
875
  if (currentRun) {
771
876
  // 八: Ctrl+C cancels BOTH the pending question (if one is
772
877
  // awaiting a line) and the run — the run then writes its unique
@@ -775,11 +880,30 @@ async function chat(session, faux) {
775
880
  pendingAsk?.();
776
881
  currentRun.abort();
777
882
  }
778
- else if (!cancelled) {
883
+ else if (pendingAsk !== null) {
884
+ pendingAsk?.(); // a startup/trust question — cancel it
885
+ }
886
+ else if (input.line() === "") {
779
887
  cancelled = true;
780
888
  console.log("\n[exit requested]");
781
- pendingAsk?.(); // unblock a startup question
782
- rl.close();
889
+ input.close();
890
+ }
891
+ else {
892
+ input.clearLine(); // v2c: Ctrl+C on a non-empty line clears it
893
+ }
894
+ });
895
+ input.onEot(() => {
896
+ if (!currentRun && pendingAsk === null && input.line() === "") {
897
+ cancelled = true;
898
+ console.log("\n[exit requested]");
899
+ input.close();
900
+ }
901
+ });
902
+ input.onEscape(() => {
903
+ if (currentRun) {
904
+ console.log("\n[aborting run]");
905
+ pendingAsk?.();
906
+ currentRun.abort();
783
907
  }
784
908
  });
785
909
  // 第五轮(P1-11): the PERSISTENT line listener is installed BEFORE the
@@ -792,23 +916,28 @@ async function chat(session, faux) {
792
916
  let chain = Promise.resolve();
793
917
  let replReady = false;
794
918
  const queuedLines = [];
795
- // B 区: user-turn counter for the status line, and the /last buffer.
919
+ // B 区: user-turn counter for the status line. /last and /think read
920
+ // the body (the ToolCell / ThinkingCell final states).
796
921
  let turnNo = 0;
797
- const lastToolRef = { current: null };
798
922
  // v2a: the last line THIS process's readline consumed — the double-echo
799
923
  // filter (see consumeRun). Only interactive chat sets it.
800
924
  const liveInput = { current: null };
801
- // v2b: the last complete thinking block, for /think.
802
- const lastThinking = { current: null };
925
+ // v2c: turns submitted while another runs are QUEUED on the chain — the
926
+ // live count rides the status bar (+N queued).
927
+ let queued = 0;
803
928
  // v2b: the live status bar (docked only).
804
929
  const statusCb = (u, ctx) => {
805
930
  if (!dock.active)
806
931
  return;
807
932
  const st = renderStatusLine(turnNo, u, ctx, faux);
808
- dock.setStatus(st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`);
933
+ const base = st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`;
934
+ dock.setStatus(queued > 0 ? `${base} · +${queued} queued` : base);
809
935
  };
810
- dock.bindInput(() => ({ line: rl.line, cursor: rl.cursor }), interactivePrompt());
811
- rl.on("line", (line) => {
936
+ // The ONE dispatcher: slash commands, exit, and turns. The recovery
937
+ // replay routes through it too — a queued "/last" must never become a
938
+ // user turn (v2c: the rl lives in main, so lines arrive earlier and
939
+ // the queue is the common path).
940
+ const dispatch = (line) => {
812
941
  const trimmed = line.trim();
813
942
  if (trimmed === "/help") {
814
943
  // Prints the available commands with one-line descriptions.
@@ -821,33 +950,31 @@ async function chat(session, faux) {
821
950
  bodyLog(cmd("/last", "show the most recent tool call's input and output"));
822
951
  bodyLog(cmd("/status", "show session id, event count, and context estimate"));
823
952
  bodyLog(cmd("exit", "leave the session"));
824
- rl.setPrompt(interactivePrompt());
825
- rl.prompt();
953
+ input.prompt();
826
954
  });
827
955
  return;
828
956
  }
829
957
  if (trimmed === "/think") {
830
- // v2b: print the last COMPLETE thinking block — straight from the
831
- // event stream, nothing stored separately (the /last pattern).
958
+ // v2b/v2d: print the last COMPLETE thinking block — the body holds
959
+ // it (the ThinkingCell's fold closes at the block's end).
832
960
  chain = chain.then(async () => {
833
- const t = lastThinking.current;
961
+ const t = body.lastThinking();
834
962
  if (t === null) {
835
963
  bodyLog("[no thinking yet]");
836
964
  }
837
965
  else {
838
966
  bodyLog(escapeTerminal(t));
839
967
  }
840
- rl.setPrompt(interactivePrompt());
841
- rl.prompt();
968
+ input.prompt();
842
969
  });
843
970
  return;
844
971
  }
845
972
  if (trimmed === "/last") {
846
- // B 区: print the FULL input/output of the most recent tool call,
847
- // straight from the event stream nothing is stored separately.
848
- // Runs on the chain: after any in-flight turn completes.
973
+ // B 区/v2d: print the FULL input/output of the most recent tool
974
+ // call the body holds it (the ToolCell's final state). Runs on
975
+ // the chain: after any in-flight turn completes.
849
976
  chain = chain.then(async () => {
850
- const tool = lastToolRef.current;
977
+ const tool = body.lastTool();
851
978
  if (tool === null) {
852
979
  bodyLog("[no tool call yet]");
853
980
  }
@@ -857,8 +984,7 @@ async function chat(session, faux) {
857
984
  bodyLog(`--- ${tool.name} output${tool.result.isError ? " (error)" : ""} ---`);
858
985
  bodyLog(escapeTerminal(tool.result.content));
859
986
  }
860
- rl.setPrompt(interactivePrompt());
861
- rl.prompt();
987
+ input.prompt();
862
988
  });
863
989
  return;
864
990
  }
@@ -872,49 +998,58 @@ async function chat(session, faux) {
872
998
  bodyLog(`session ${session.id}`);
873
999
  bodyLog(`${session.log.all.length} events`);
874
1000
  bodyLog(`ctx ${ctx}`);
875
- rl.setPrompt(interactivePrompt());
876
- rl.prompt();
1001
+ input.prompt();
877
1002
  });
878
1003
  return;
879
1004
  }
880
1005
  if (trimmed === "exit" || trimmed === "") {
881
- rl.close();
1006
+ input.close();
882
1007
  return;
883
1008
  }
1009
+ // v2c: a turn submitted while another runs waits on the chain — the
1010
+ // live count rides the status bar (+N queued).
1011
+ queued += 1;
1012
+ chain = chain.then(() => turn(line));
1013
+ };
1014
+ input.onLine((line) => {
884
1015
  if (!replReady) {
885
1016
  queuedLines.push(line);
886
1017
  return;
887
1018
  }
888
- chain = chain.then(() => turn(line));
1019
+ dispatch(line);
889
1020
  });
890
1021
  // Recovery first: a session with a dangling pause or uncertain
891
1022
  // executions must resolve them BEFORE the REPL accepts new turns —
892
1023
  // otherwise the interrupted run dangles while a new one starts.
893
1024
  // 八: the startup resume is bound to currentRun — Ctrl+C during it
894
1025
  // aborts the recovery, exactly like the interactive turns.
895
- await resolveUncertains(session, rl, () => cancelled);
1026
+ await resolveUncertains(session, input, () => cancelled);
896
1027
  if (!cancelled) {
897
1028
  const recoveryRun = session.resume();
898
1029
  currentRun = recoveryRun;
899
1030
  turnNo += 1;
900
- const last = await consumeRun(session, recoveryRun, rl, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb);
1031
+ const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
901
1032
  currentRun = null;
902
- failOnFauxExhaustion(last, faux, rl);
1033
+ failOnFauxExhaustion(last, faux, input);
903
1034
  }
904
1035
  if (cancelled) {
905
- rl.close();
906
- await new Promise((resolve) => rl.on("close", () => resolve()));
1036
+ input.close();
1037
+ await input.closed;
907
1038
  return;
908
1039
  }
909
1040
  // The REPL is ready: replay anything that arrived during recovery.
910
1041
  replReady = true;
1042
+ // v2c: dispatch SYNCHRONOUSLY — each call appends its segment to the
1043
+ // chain variable; the final `await chain` then covers every replayed
1044
+ // turn. A chain.then(() => dispatch()) indirection would capture the
1045
+ // chain BEFORE the appends and the replayed turns would never be
1046
+ // awaited (the F-group regression).
911
1047
  for (const line of queuedLines) {
912
- chain = chain.then(() => turn(line));
1048
+ dispatch(line);
913
1049
  }
914
1050
  queuedLines.length = 0;
915
- rl.setPrompt(interactivePrompt());
916
- rl.prompt();
917
- await new Promise((resolve) => rl.on("close", () => resolve()));
1051
+ input.prompt();
1052
+ await input.closed;
918
1053
  await chain; // never exit while a turn is in flight
919
1054
  }
920
1055
  /**
@@ -924,13 +1059,10 @@ async function chat(session, faux) {
924
1059
  * E 组: SIGINT aborts the run being resumed; every exit path closes the
925
1060
  * session store so no lock is left behind.
926
1061
  */
927
- async function resume(session, prompt, faux) {
928
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1062
+ async function resume(session, prompt, faux, input) {
929
1063
  let currentRun = null;
930
1064
  let cancelled = false;
931
1065
  let turnNo = 0;
932
- const lastToolRef = { current: null };
933
- const lastThinking = { current: null };
934
1066
  // v2b: the live status bar (docked only).
935
1067
  const statusCb = (u, ctx) => {
936
1068
  if (!dock.active)
@@ -938,19 +1070,18 @@ async function resume(session, prompt, faux) {
938
1070
  const st = renderStatusLine(turnNo, u, ctx, faux);
939
1071
  dock.setStatus(st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`);
940
1072
  };
941
- dock.bindInput(() => ({ line: rl.line, cursor: rl.cursor }), interactivePrompt());
942
1073
  const withRun = async (run) => {
943
1074
  currentRun = run;
944
1075
  try {
945
1076
  turnNo += 1;
946
- const last = await consumeRun(session, run, rl, turnNo, lastToolRef, faux, null, lastThinking, statusCb);
947
- failOnFauxExhaustion(last, faux, rl);
1077
+ const last = await consumeRun(session, run, input, turnNo, faux, null, statusCb);
1078
+ failOnFauxExhaustion(last, faux, input);
948
1079
  }
949
1080
  finally {
950
1081
  currentRun = null;
951
1082
  }
952
1083
  };
953
- rl.on("SIGINT", () => {
1084
+ input.onSigint(() => {
954
1085
  if (currentRun) {
955
1086
  // 八: Ctrl+C cancels the pending question AND the run.
956
1087
  console.log("\n[aborting run]");
@@ -965,11 +1096,25 @@ async function resume(session, prompt, faux) {
965
1096
  cancelled = true;
966
1097
  console.log("\n[exit requested]");
967
1098
  pendingAsk?.();
968
- rl.close();
1099
+ input.close();
1100
+ }
1101
+ });
1102
+ input.onEot(() => {
1103
+ if (!currentRun && !cancelled && input.line() === "") {
1104
+ cancelled = true;
1105
+ console.log("\n[exit requested]");
1106
+ input.close();
1107
+ }
1108
+ });
1109
+ input.onEscape(() => {
1110
+ if (currentRun) {
1111
+ console.log("\n[aborting run]");
1112
+ pendingAsk?.();
1113
+ currentRun.abort();
969
1114
  }
970
1115
  });
971
1116
  try {
972
- await resolveUncertains(session, rl, () => cancelled);
1117
+ await resolveUncertains(session, input, () => cancelled);
973
1118
  if (!cancelled) {
974
1119
  await withRun(session.resume());
975
1120
  if (prompt !== undefined && prompt !== "") {
@@ -978,7 +1123,7 @@ async function resume(session, prompt, faux) {
978
1123
  }
979
1124
  }
980
1125
  finally {
981
- rl.close();
1126
+ input.close();
982
1127
  }
983
1128
  }
984
1129
  async function main() {
@@ -987,6 +1132,19 @@ async function main() {
987
1132
  // exit non-zero, never masquerade as a successful provider run.
988
1133
  const faux = process.env.ANTHROPIC_API_KEY === undefined && process.env.OPENAI_API_KEY === undefined;
989
1134
  let agent;
1135
+ // v2c: ONE input source per process — the raw-mode editor on a TTY
1136
+ // (entered here, dock-bound, trusted before any extension loads),
1137
+ // readline elsewhere. The trust question, chat, and resume all read
1138
+ // through it; main's finally closes it on every exit path.
1139
+ const input = makeLineInput();
1140
+ // v2d: the body renderer — active only where the dock is (a color
1141
+ // TTY with a real size); pipes run it in passthrough, byte-for-byte.
1142
+ body = new Body({
1143
+ active: () => process.stdin.isTTY && palette().blue !== "" && (process.stdout.rows ?? 0) >= 4,
1144
+ height: () => process.stdout.rows ?? 24,
1145
+ width: () => process.stdout.columns ?? 80,
1146
+ editCol: () => dock.editCol(),
1147
+ });
990
1148
  try {
991
1149
  switch (command) {
992
1150
  case "chat": {
@@ -996,11 +1154,11 @@ async function main() {
996
1154
  dock.enter();
997
1155
  // E 区: a resumed session continues the script at its durable
998
1156
  // position — never restarts it (fauxSkip).
999
- const agent = await makeAgent(fauxSkip(id));
1157
+ const agent = await makeAgent(fauxSkip(id), input);
1000
1158
  const session = await agent.session({ id });
1001
1159
  bodyLog(`session ${id}\n`);
1002
1160
  extensionsBanner();
1003
- await chat(session, faux);
1161
+ await chat(session, faux, input);
1004
1162
  break;
1005
1163
  }
1006
1164
  case "resume": {
@@ -1012,9 +1170,9 @@ async function main() {
1012
1170
  // (argv = [node, script, resume, id, prompt?]).
1013
1171
  const prompt = process.argv[4];
1014
1172
  dock.enter();
1015
- const agent = await makeAgent(fauxSkip(arg));
1173
+ const agent = await makeAgent(fauxSkip(arg), input);
1016
1174
  const session = await agent.session({ id: arg });
1017
- await resume(session, prompt, faux);
1175
+ await resume(session, prompt, faux, input);
1018
1176
  break;
1019
1177
  }
1020
1178
  case "sessions": {
@@ -1045,12 +1203,14 @@ async function main() {
1045
1203
  const session = await agent.session({ id });
1046
1204
  bodyLog(`session ${id}\n`);
1047
1205
  extensionsBanner();
1048
- await chat(session, faux);
1206
+ await chat(session, faux, input);
1049
1207
  break;
1050
1208
  }
1051
1209
  }
1052
1210
  }
1053
1211
  finally {
1212
+ body.close(); // flush the pending frame, stop the heartbeat
1213
+ input.close();
1054
1214
  // E 组: every normal and abnormal exit releases the fds and writer
1055
1215
  // locks — no lock file is left behind.
1056
1216
  agent?.close();