@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/body.d.ts +118 -0
- package/dist/body.js +419 -0
- package/dist/dock.d.ts +30 -9
- package/dist/dock.js +47 -16
- package/dist/editor.d.ts +64 -0
- package/dist/editor.js +378 -0
- package/dist/index.js +360 -200
- package/package.json +7 -7
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
|
-
/**
|
|
99
|
-
* the
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
bodyLog(`
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
681
|
+
async function resolveUncertains(session, input, isCancelled) {
|
|
564
682
|
for (const uncertain of session.uncertainExecutions()) {
|
|
565
|
-
const answer = await ask(
|
|
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,
|
|
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
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
// render
|
|
617
|
-
|
|
618
|
-
|
|
708
|
+
// v2a (双回显): the interactive echo was already rendered by the
|
|
709
|
+
// input source — rendering the event again is the double echo.
|
|
710
|
+
// v2b: DOCKED — the 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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
643
|
-
|
|
743
|
+
body.toolResult(ev.callId, { content: text, isError: ev.isError });
|
|
744
|
+
break;
|
|
644
745
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 = (
|
|
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 =
|
|
731
|
-
const run = session.run(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
764
|
-
rl.prompt();
|
|
869
|
+
input.prompt();
|
|
765
870
|
resolve();
|
|
766
871
|
}
|
|
767
872
|
})();
|
|
768
873
|
});
|
|
769
|
-
|
|
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 (
|
|
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
|
-
|
|
782
|
-
|
|
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
|
|
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
|
-
//
|
|
802
|
-
|
|
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
|
-
|
|
933
|
+
const base = st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`;
|
|
934
|
+
dock.setStatus(queued > 0 ? `${base} · +${queued} queued` : base);
|
|
809
935
|
};
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
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 —
|
|
831
|
-
//
|
|
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
|
|
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
|
-
|
|
841
|
-
rl.prompt();
|
|
968
|
+
input.prompt();
|
|
842
969
|
});
|
|
843
970
|
return;
|
|
844
971
|
}
|
|
845
972
|
if (trimmed === "/last") {
|
|
846
|
-
// B
|
|
847
|
-
//
|
|
848
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
876
|
-
rl.prompt();
|
|
1001
|
+
input.prompt();
|
|
877
1002
|
});
|
|
878
1003
|
return;
|
|
879
1004
|
}
|
|
880
1005
|
if (trimmed === "exit" || trimmed === "") {
|
|
881
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
1031
|
+
const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
|
|
901
1032
|
currentRun = null;
|
|
902
|
-
failOnFauxExhaustion(last, faux,
|
|
1033
|
+
failOnFauxExhaustion(last, faux, input);
|
|
903
1034
|
}
|
|
904
1035
|
if (cancelled) {
|
|
905
|
-
|
|
906
|
-
await
|
|
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
|
-
|
|
1048
|
+
dispatch(line);
|
|
913
1049
|
}
|
|
914
1050
|
queuedLines.length = 0;
|
|
915
|
-
|
|
916
|
-
|
|
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,
|
|
947
|
-
failOnFauxExhaustion(last, faux,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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();
|