@vincemakes/kiso-code 0.1.13
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/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/dock.d.ts +55 -0
- package/dist/dock.js +152 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +1086 -0
- package/dist/render.d.ts +105 -0
- package/dist/render.js +265 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* kiso — the coding-agent reference product.
|
|
4
|
+
*
|
|
5
|
+
* kiso chat [sessionId] start or continue an interactive session
|
|
6
|
+
* kiso resume <sessionId> continue a session in one-shot mode
|
|
7
|
+
* kiso sessions list durable sessions
|
|
8
|
+
*
|
|
9
|
+
* Provider selection (first match):
|
|
10
|
+
* ANTHROPIC_API_KEY → Anthropic (ANTHROPIC_MODEL, default claude-sonnet-5)
|
|
11
|
+
* OPENAI_API_KEY → OpenAI-compatible (OPENAI_MODEL, OPENAI_BASE_URL)
|
|
12
|
+
* neither → faux mode: scripted model, zero keys, full CLI
|
|
13
|
+
*
|
|
14
|
+
* Sessions live under $KISO_HOME/sessions (default ~/.kiso/sessions) as
|
|
15
|
+
* append-only JSONL. Write/edit/shell tools sit behind the approval policy:
|
|
16
|
+
* the run pauses, asks, and resumes — durably (ADR-0024).
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { createInterface } from "node:readline";
|
|
20
|
+
import { homedir, tmpdir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, projectArtifacts, recordTrust, SessionStore, trustFor, } from "@vincemakes/kiso-runtime";
|
|
24
|
+
import { createFauxProvider } from "@vincemakes/kiso-evals";
|
|
25
|
+
import { createCodingTools } from "@vincemakes/kiso-tools-node";
|
|
26
|
+
import { escapeTerminal, foldResult, foldThinking, palette, renderEvent, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, } from "./render.js";
|
|
27
|
+
import { Dock } from "./dock.js";
|
|
28
|
+
const PERMISSION_POLICY = {
|
|
29
|
+
rules: [
|
|
30
|
+
{ tool: "read_file", action: "allow" },
|
|
31
|
+
{ tool: "list_dir", action: "allow" },
|
|
32
|
+
{ tool: "search_text", action: "allow" },
|
|
33
|
+
{ tool: "write_file", action: "defer" },
|
|
34
|
+
{ tool: "edit_file", action: "defer" },
|
|
35
|
+
{ tool: "shell", action: "defer" },
|
|
36
|
+
],
|
|
37
|
+
default: "deny",
|
|
38
|
+
};
|
|
39
|
+
/** 发现#11: KISO_HOME is the ONE root — every default path derives from
|
|
40
|
+
* it (sessions, trust, extensions, mcp config, skills). The dedicated
|
|
41
|
+
* env vars (KISO_EXTENSIONS_DIR / KISO_MCP_CONFIG / KISO_SKILLS_DIR)
|
|
42
|
+
* still override their own path; nothing hard-codes ~/.kiso anymore. */
|
|
43
|
+
function kisoHome() {
|
|
44
|
+
return process.env.KISO_HOME ?? join(homedir(), ".kiso");
|
|
45
|
+
}
|
|
46
|
+
function sessionsDir() {
|
|
47
|
+
return join(kisoHome(), "sessions");
|
|
48
|
+
}
|
|
49
|
+
/** E1: the extension scan directory — KISO_EXTENSIONS_DIR overrides. */
|
|
50
|
+
function extensionsDir() {
|
|
51
|
+
return process.env.KISO_EXTENSIONS_DIR ?? join(kisoHome(), "extensions");
|
|
52
|
+
}
|
|
53
|
+
/** E1: the extensions loaded by makeAgent — their names feed the banner. */
|
|
54
|
+
let loadedExtensions = [];
|
|
55
|
+
/** E1: the USER-level extensions alone — the banner's unmarked part (E3:
|
|
56
|
+
* loadedExtensions later includes the project-level ones too). */
|
|
57
|
+
let userExtensions = [];
|
|
58
|
+
/** E3: the PROJECT-level extensions (loaded after the trust gate) — the
|
|
59
|
+
* banner distinguishes them from the user-level ones. */
|
|
60
|
+
let projectExtensions = [];
|
|
61
|
+
/** E3: temp artifacts of the mcp/skills merge — removed on exit. */
|
|
62
|
+
const mergedTempPaths = [];
|
|
63
|
+
/** The CLI's own version — read from the package.json next to the build. */
|
|
64
|
+
let VERSION = "?";
|
|
65
|
+
try {
|
|
66
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
|
|
67
|
+
VERSION = pkg.version ?? "?";
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// a packed CLI without a readable package.json still works
|
|
71
|
+
}
|
|
72
|
+
/** 横幅: the block-letter logo (design fixed). TTY only — pipes, e2e
|
|
73
|
+
* drivers, and CI see byte-for-byte the old output; the extensions line
|
|
74
|
+
* merges into the third row on TTY and stays a standalone line off-TTY.
|
|
75
|
+
* v2a: the logo rows stay dim; the TAGLINE (row 2) is the blue identity
|
|
76
|
+
* accent. */
|
|
77
|
+
const LOGO_TOP = "█ █ ▀█▀ █▀▀ █▀█\n█▀▄ █ ▀▀█ █ █ ";
|
|
78
|
+
const TAGLINE = "the coding agent that survives kill -9";
|
|
79
|
+
const LOGO_BOTTOM = "\n▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀";
|
|
80
|
+
function startupBanner() {
|
|
81
|
+
// The historical `[N extensions: names]` text merges VERBATIM into the
|
|
82
|
+
// third row — the existing e2e assertions keep matching (天然不破). E3:
|
|
83
|
+
// project-level extensions are counted in N and listed after `project:`
|
|
84
|
+
// — `[3 extensions: safe-defaults · project: lint-rules, mcp]`.
|
|
85
|
+
const p = palette();
|
|
86
|
+
const names = bannerExtensionText();
|
|
87
|
+
return `${p.dim}${LOGO_TOP}${p.blue}${TAGLINE}${p.reset}${p.dim}${LOGO_BOTTOM} v${VERSION}${names}${p.reset}\n`;
|
|
88
|
+
}
|
|
89
|
+
/** v2a: the interactive prompt — blue, the identity accent. readline owns
|
|
90
|
+
* the echo of what the user types; we own the prompt's color. */
|
|
91
|
+
function interactivePrompt() {
|
|
92
|
+
const p = palette();
|
|
93
|
+
return `${p.blue}you> ${p.reset}`;
|
|
94
|
+
}
|
|
95
|
+
/** v2b: the bottom-anchored UI — docked only on a color TTY; pipes and
|
|
96
|
+
* NO_COLOR stay the v2a line mode byte-for-byte. */
|
|
97
|
+
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
|
+
}
|
|
107
|
+
function bodyLog(text) {
|
|
108
|
+
bodyWrite(`${text}\n`);
|
|
109
|
+
}
|
|
110
|
+
/** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
|
|
111
|
+
* is gone) — docked only, 200ms rotation between the request and the
|
|
112
|
+
* first event. */
|
|
113
|
+
function startStatusSpinner() {
|
|
114
|
+
if (!dock.active)
|
|
115
|
+
return () => { };
|
|
116
|
+
const GLYPHS = ["◐", "◓", "◑", "◒"];
|
|
117
|
+
let i = 0;
|
|
118
|
+
const timer = setInterval(() => dock.setTail(GLYPHS[i++ % GLYPHS.length]), 200);
|
|
119
|
+
timer.unref();
|
|
120
|
+
return () => dock.setTail("");
|
|
121
|
+
}
|
|
122
|
+
/** v2b: "running <tool> Ns" in the status bar while a tool executes. */
|
|
123
|
+
function startRunningTimer(name) {
|
|
124
|
+
if (!dock.active)
|
|
125
|
+
return () => { };
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
const timer = setInterval(() => dock.setTail(`running ${name} ${Math.round((Date.now() - started) / 1000)}s`), 1000);
|
|
128
|
+
timer.unref();
|
|
129
|
+
return () => {
|
|
130
|
+
clearInterval(timer);
|
|
131
|
+
dock.setTail("");
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** The model name for the status bar — set by makeAgent. */
|
|
135
|
+
let agentModel = "faux";
|
|
136
|
+
/** E3: the `[N extensions: ...]` text — user-level names, then project-level
|
|
137
|
+
* ones marked with `project:`. Byte-identical to the historical text when
|
|
138
|
+
* no project extensions are loaded. */
|
|
139
|
+
function bannerExtensionText() {
|
|
140
|
+
const total = userExtensions.length + projectExtensions.length;
|
|
141
|
+
if (total === 0)
|
|
142
|
+
return "";
|
|
143
|
+
const parts = [];
|
|
144
|
+
if (userExtensions.length > 0)
|
|
145
|
+
parts.push(userExtensions.map((e) => e.name).join(", "));
|
|
146
|
+
if (projectExtensions.length > 0)
|
|
147
|
+
parts.push(`project: ${projectExtensions.map((e) => e.name).join(", ")}`);
|
|
148
|
+
return ` · [${total} extension${total === 1 ? "" : "s"}: ${parts.join(" · ")}]`;
|
|
149
|
+
}
|
|
150
|
+
/** E1: the startup banner line(s) — TTY: logo + merged extensions; off-TTY:
|
|
151
|
+
* the historical `[N extensions: ...]` standalone line (zero change). */
|
|
152
|
+
function extensionsBanner() {
|
|
153
|
+
if (process.stdout.isTTY) {
|
|
154
|
+
bodyLog(startupBanner());
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const text = bannerExtensionText();
|
|
158
|
+
if (text === "")
|
|
159
|
+
return;
|
|
160
|
+
bodyLog(`${text}\n`);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* E3 — the project-level trust gate (ADR-0037): capability is trusted by
|
|
164
|
+
* content digest, not by directory. Runs BEFORE any extension loads — the
|
|
165
|
+
* mcp/skills merges must be in the env before the user-level extensions are
|
|
166
|
+
* loaded (the mcp factory reads KISO_MCP_CONFIG at load time, the skills
|
|
167
|
+
* extension scans KISO_SKILLS_DIR at load time).
|
|
168
|
+
*
|
|
169
|
+
* Verdicts: granted → load; refused → never load, never re-ask (refused is
|
|
170
|
+
* sticky — re-evaluate by deleting the trust line or changing a file); no
|
|
171
|
+
* record → only a HUMAN may decide, TTY only — non-TTY refuses with one
|
|
172
|
+
* stderr line. Returns the artifacts on grant, null on anything else.
|
|
173
|
+
*/
|
|
174
|
+
async function resolveProjectTrust() {
|
|
175
|
+
const artifacts = await projectArtifacts(process.cwd());
|
|
176
|
+
if (artifacts === null)
|
|
177
|
+
return null; // no .kiso artifacts — nothing to gate
|
|
178
|
+
const record = trustFor(artifacts.root, artifacts.digest);
|
|
179
|
+
if (record?.decision === "granted") {
|
|
180
|
+
applyProjectMerges(artifacts);
|
|
181
|
+
return artifacts;
|
|
182
|
+
}
|
|
183
|
+
if (record?.decision === "refused")
|
|
184
|
+
return null; // refused is sticky — no re-ask
|
|
185
|
+
// First discovery — list every artifact (file name + digest short
|
|
186
|
+
// prefix) and ask the human ONCE.
|
|
187
|
+
if (!process.stdin.isTTY) {
|
|
188
|
+
console.error(`[project .kiso] found ${artifacts.files.length} artifact(s) in ${artifacts.root} — not trusted, not loaded (run kiso interactively once to decide)`);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
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();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* E3 — merge the project's mcp.json and skills into the env BEFORE the
|
|
213
|
+
* extension load. A server name in BOTH configs is a LOUD error (a silent
|
|
214
|
+
* override would be a supply-chain surprise); a skill name in both merges
|
|
215
|
+
* with project-wins and a stderr note. Exported for tests.
|
|
216
|
+
*/
|
|
217
|
+
export function applyProjectMerges(artifacts) {
|
|
218
|
+
if (artifacts.files.some((f) => f.kind === "mcp"))
|
|
219
|
+
applyMcpMerge(artifacts.root);
|
|
220
|
+
if (artifacts.files.some((f) => f.kind === "skill"))
|
|
221
|
+
applySkillsMerge(artifacts.root);
|
|
222
|
+
}
|
|
223
|
+
/** Read an mcp.json with the mcp extension's tolerance: absent/unreadable →
|
|
224
|
+
* {}, present-but-broken → throw (the loader convention). */
|
|
225
|
+
function readMcpConfig(path) {
|
|
226
|
+
let text;
|
|
227
|
+
try {
|
|
228
|
+
text = readFileSync(path, "utf8");
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return {};
|
|
232
|
+
}
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = JSON.parse(text);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
throw new Error(`[project .kiso] cannot parse ${path}: ${err.message}`);
|
|
239
|
+
}
|
|
240
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
241
|
+
throw new Error(`[project .kiso] ${path} must be an object with an mcpServers map`);
|
|
242
|
+
}
|
|
243
|
+
return parsed;
|
|
244
|
+
}
|
|
245
|
+
/** Merge user-level + project-level mcp.json into one temp file and point
|
|
246
|
+
* KISO_MCP_CONFIG at it — the mcp extension reads it at load time. */
|
|
247
|
+
function applyMcpMerge(root) {
|
|
248
|
+
const userPath = process.env.KISO_MCP_CONFIG ?? join(kisoHome(), "mcp.json");
|
|
249
|
+
const user = readMcpConfig(userPath);
|
|
250
|
+
const project = readMcpConfig(join(root, "mcp.json"));
|
|
251
|
+
const userServers = user.mcpServers ?? {};
|
|
252
|
+
const projectServers = project.mcpServers ?? {};
|
|
253
|
+
if (Object.keys(projectServers).length === 0)
|
|
254
|
+
return; // nothing to merge
|
|
255
|
+
for (const name of Object.keys(projectServers)) {
|
|
256
|
+
if (name in userServers) {
|
|
257
|
+
throw new Error(`[project .kiso] mcp server "${name}" exists in both the user-level and the project-level mcp.json`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const merged = { mcpServers: { ...userServers, ...projectServers } };
|
|
261
|
+
const temp = join(tmpdir(), `kiso-mcp-merged-${process.pid}.json`);
|
|
262
|
+
writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
263
|
+
process.env.KISO_MCP_CONFIG = temp;
|
|
264
|
+
mergedTempPaths.push(temp);
|
|
265
|
+
}
|
|
266
|
+
/** Merge user-level + project-level skills into one temp scan dir (project
|
|
267
|
+
* skill dirs symlinked first; a name in both → project wins + a stderr
|
|
268
|
+
* note) and point KISO_SKILLS_DIR at it — the skills extension's existing
|
|
269
|
+
* scan reads it at load time and per read_skill call. */
|
|
270
|
+
function applySkillsMerge(root) {
|
|
271
|
+
const userDir = process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
|
|
272
|
+
const projectDir = join(root, "skills");
|
|
273
|
+
const merged = mkdtempSync(join(tmpdir(), "kiso-skills-"));
|
|
274
|
+
mergedTempPaths.push(merged);
|
|
275
|
+
for (const dir of readdirSyncSafe(projectDir)) {
|
|
276
|
+
symlinkSync(join(projectDir, dir), join(merged, dir)); // project wins on collision
|
|
277
|
+
}
|
|
278
|
+
for (const dir of readdirSyncSafe(userDir)) {
|
|
279
|
+
const target = join(merged, dir);
|
|
280
|
+
if (existsSync(target)) {
|
|
281
|
+
console.error(`[project .kiso] skill "${dir}" exists in both user and project skills — project wins`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
symlinkSync(join(userDir, dir), target);
|
|
285
|
+
}
|
|
286
|
+
process.env.KISO_SKILLS_DIR = merged;
|
|
287
|
+
}
|
|
288
|
+
function readdirSyncSafe(dir) {
|
|
289
|
+
try {
|
|
290
|
+
return readdirSync(dir);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return []; // no skills dir on either level = nothing to merge
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A 区: the coding-agent system prompt — ONE constant, byte-stable for the
|
|
298
|
+
* session's lifetime (D 区). Kept under ~80 lines; no template engine.
|
|
299
|
+
*/
|
|
300
|
+
const SYSTEM_PROMPT = `You are kiso, a coding agent. You work in a workspace
|
|
301
|
+
directory and change code with tools. Be concise: answer in a few lines
|
|
302
|
+
unless the task genuinely needs more. Never claim a file was changed
|
|
303
|
+
unless a tool confirmed it.
|
|
304
|
+
|
|
305
|
+
Tool discipline:
|
|
306
|
+
- READ BEFORE YOU EDIT. For any file you are about to change, read it
|
|
307
|
+
first — never guess its content.
|
|
308
|
+
- Use edit_file for targeted changes and write_file for full rewrites.
|
|
309
|
+
Prefer many small edits over one large write.
|
|
310
|
+
- shell is for commands: builds, tests, git, grep. Be careful — shell has
|
|
311
|
+
side effects and may take time. Run one command at a time and inspect
|
|
312
|
+
the output before continuing.
|
|
313
|
+
- search_text and list_dir are cheap — use them to orient before reading
|
|
314
|
+
whole files.
|
|
315
|
+
- When a tool fails, read the error and adjust; do not repeat the same
|
|
316
|
+
call blindly.
|
|
317
|
+
|
|
318
|
+
Workflow: understand the request, find the relevant code, make the
|
|
319
|
+
smallest change that works, then verify with a command (tests/build).
|
|
320
|
+
Report what you did in one or two lines per change.`;
|
|
321
|
+
/** The project-instructions file names, in priority order (A 区). */
|
|
322
|
+
const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
323
|
+
/** Hard cap for injected instructions — truncate and say so. */
|
|
324
|
+
const INSTRUCTION_MAX = 8 * 1024;
|
|
325
|
+
/**
|
|
326
|
+
* A 区: read the FIRST present instruction file (AGENTS.md preferred) and
|
|
327
|
+
* return it as an injected section, or "" when none exists. Truncated at
|
|
328
|
+
* 8KB with an explicit note. Pure — read once per session, so the prompt
|
|
329
|
+
* is byte-stable for the session's lifetime.
|
|
330
|
+
*/
|
|
331
|
+
export function readProjectInstructions(cwd) {
|
|
332
|
+
for (const name of INSTRUCTION_FILES) {
|
|
333
|
+
let text;
|
|
334
|
+
try {
|
|
335
|
+
text = readFileSync(join(cwd, name), "utf8");
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
continue; // not present — try the next
|
|
339
|
+
}
|
|
340
|
+
const body = text.length > INSTRUCTION_MAX ? text.slice(0, INSTRUCTION_MAX) + `\n\n[truncated at ${INSTRUCTION_MAX} chars]` : text;
|
|
341
|
+
return `\n\n=== Project instructions (${name}) ===\n${body}`;
|
|
342
|
+
}
|
|
343
|
+
return "";
|
|
344
|
+
}
|
|
345
|
+
/** A 区: the session's system prompt — the constant plus any project
|
|
346
|
+
* instructions found in the workspace. Deterministic per cwd. */
|
|
347
|
+
export function composeSystemPrompt(cwd) {
|
|
348
|
+
const injected = readProjectInstructions(cwd);
|
|
349
|
+
return injected === "" ? SYSTEM_PROMPT : `${SYSTEM_PROMPT}\n${injected}`;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* E 区: how many faux-script turns a session has already consumed. The faux
|
|
353
|
+
* provider's script counter is per-process, so a FRESH process that resumes
|
|
354
|
+
* a session would restart the script at turn 0 — re-issuing the first
|
|
355
|
+
* scripted call instead of continuing the trajectory. The session log is
|
|
356
|
+
* the durable position: a turn is consumed when it produced a tool_result
|
|
357
|
+
* or an end_turn stop — AND when its tool call is unfinished (started but
|
|
358
|
+
* no result): the recovery completes those turns WITHOUT a provider call
|
|
359
|
+
* (executes the approved call, or fills the human verdict), so the model's
|
|
360
|
+
* next response is the turn AFTER them.
|
|
361
|
+
*/
|
|
362
|
+
function fauxSkip(id) {
|
|
363
|
+
const events = new SessionStore(sessionsDir())
|
|
364
|
+
.load(id)
|
|
365
|
+
.map((r) => r.event);
|
|
366
|
+
const results = new Set(events.filter((e) => e.type === "tool_result").map((e) => e.callId));
|
|
367
|
+
return (events.filter((e) => e.type === "tool_result").length +
|
|
368
|
+
events.filter((e) => e.type === "stop" && e.reason === "end_turn").length +
|
|
369
|
+
events.filter((e) => e.type === "tool_call_end" && !results.has(e.callId)).length);
|
|
370
|
+
}
|
|
371
|
+
async function makeAgent(fauxSkipTurns = 0) {
|
|
372
|
+
const store = new SessionStore(sessionsDir());
|
|
373
|
+
// E3: the project-level trust gate runs BEFORE any extension load (the
|
|
374
|
+
// mcp/skills merges must be in the env when the user-level extensions
|
|
375
|
+
// load). Untrusted project capability is never loaded — never silently.
|
|
376
|
+
const project = await resolveProjectTrust();
|
|
377
|
+
// E1: the startup extension scan — a broken extension fails the process
|
|
378
|
+
// LOUDLY here (loadExtensions throws), never silently.
|
|
379
|
+
userExtensions = await loadExtensions(extensionsDir());
|
|
380
|
+
if (project !== null) {
|
|
381
|
+
projectExtensions = await loadProjectExtensions(process.cwd(), userExtensions);
|
|
382
|
+
loadedExtensions = [...userExtensions, ...projectExtensions];
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
projectExtensions = [];
|
|
386
|
+
loadedExtensions = userExtensions;
|
|
387
|
+
}
|
|
388
|
+
// Provider wiring (F 组): the CLI never imports provider SDKs directly —
|
|
389
|
+
// the runtime's lazy provider resolution owns them. Real key → real
|
|
390
|
+
// provider; none → faux.
|
|
391
|
+
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
392
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
393
|
+
let provider;
|
|
394
|
+
let model;
|
|
395
|
+
if (anthropicKey) {
|
|
396
|
+
provider = "anthropic";
|
|
397
|
+
model = process.env.ANTHROPIC_MODEL ?? "claude-sonnet-5";
|
|
398
|
+
}
|
|
399
|
+
else if (openaiKey) {
|
|
400
|
+
provider = "openai-compat";
|
|
401
|
+
model = process.env.OPENAI_MODEL ?? "gpt-4o";
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
console.log("[faux mode — set ANTHROPIC_API_KEY or OPENAI_API_KEY for a real model]\n");
|
|
405
|
+
model = "faux";
|
|
406
|
+
}
|
|
407
|
+
agentModel = model; // v2b: the status bar shows it
|
|
408
|
+
const definition = {
|
|
409
|
+
model,
|
|
410
|
+
store,
|
|
411
|
+
// Area 5: the coding tools are bound to the workspace — every path
|
|
412
|
+
// they touch is canonicalized inside cwd, escapes are refused.
|
|
413
|
+
tools: [...createCodingTools({ workspaceRoot: process.cwd() })],
|
|
414
|
+
permissionPolicy: PERMISSION_POLICY,
|
|
415
|
+
systemPrompt: composeSystemPrompt(process.cwd()),
|
|
416
|
+
// C 区: microcompact is ON by default in the product — threshold =
|
|
417
|
+
// half the model window (KISO_CONTEXT_WINDOW override included;
|
|
418
|
+
// 200k window → 100k tokens). Long sessions compact old read/list/
|
|
419
|
+
// search/shell outputs instead of silently growing past the window.
|
|
420
|
+
microcompact: { thresholdTokens: contextWindowTokens() / 2 },
|
|
421
|
+
maxTurns: 20,
|
|
422
|
+
extensions: loadedExtensions,
|
|
423
|
+
...(provider !== undefined
|
|
424
|
+
? {
|
|
425
|
+
provider,
|
|
426
|
+
apiKey: (anthropicKey ?? openaiKey),
|
|
427
|
+
...(process.env.OPENAI_BASE_URL !== undefined ? { baseUrl: process.env.OPENAI_BASE_URL } : {}),
|
|
428
|
+
}
|
|
429
|
+
: { adapter: createFauxProvider(readFauxScript().slice(fauxSkipTurns)) }),
|
|
430
|
+
};
|
|
431
|
+
return createAgent(definition);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* E 区: KISO_FAUX_SCRIPT=<path> overrides the demo script with a JSON
|
|
435
|
+
* FauxScript file — the kill -9 e2e drives the CLI through an exact
|
|
436
|
+
* multi-tool trajectory. Absent → the built-in demo script.
|
|
437
|
+
*/
|
|
438
|
+
function readFauxScript() {
|
|
439
|
+
const path = process.env.KISO_FAUX_SCRIPT;
|
|
440
|
+
if (path === undefined)
|
|
441
|
+
return fauxScript();
|
|
442
|
+
try {
|
|
443
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
444
|
+
if (!Array.isArray(parsed))
|
|
445
|
+
throw new Error("not an array");
|
|
446
|
+
return parsed;
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
console.error(`[KISO_FAUX_SCRIPT] cannot load ${path}: ${err.message}`);
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The keyless demo script: tours the tools so `kiso chat` exercises them.
|
|
455
|
+
* FOUR turns: each user turn consumes two model rounds (call → result →
|
|
456
|
+
* summary), so at least two consecutive user turns work in one process
|
|
457
|
+
* (F 组).
|
|
458
|
+
*/
|
|
459
|
+
function fauxScript() {
|
|
460
|
+
return [
|
|
461
|
+
{
|
|
462
|
+
events: [
|
|
463
|
+
// 自举 P1: a multi-delta thinking block — renders as ONE
|
|
464
|
+
// streaming segment, not one line per token.
|
|
465
|
+
{ type: "thinking", text: "Let me think about" },
|
|
466
|
+
{ type: "thinking", text: " the workspace" },
|
|
467
|
+
{ type: "thinking", text: " before acting." },
|
|
468
|
+
{ type: "text_start" },
|
|
469
|
+
{ type: "text_delta", text: "I'm the faux model. Let me look at the working directory." },
|
|
470
|
+
{ type: "tool_call_end", callId: "c1", name: "list_dir", input: {} },
|
|
471
|
+
{ type: "stop", reason: "tool_use" },
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
events: [
|
|
476
|
+
{ type: "text_delta", text: "I see the workspace. What would you like me to inspect or change?" },
|
|
477
|
+
{ type: "stop", reason: "end_turn" },
|
|
478
|
+
],
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
events: [
|
|
482
|
+
{ type: "text_delta", text: "The faux model is still here, with full context." },
|
|
483
|
+
{ type: "stop", reason: "end_turn" },
|
|
484
|
+
],
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
events: [
|
|
488
|
+
{ type: "text_delta", text: "And this is the end of the scripted tour." },
|
|
489
|
+
{ type: "stop", reason: "end_turn" },
|
|
490
|
+
],
|
|
491
|
+
},
|
|
492
|
+
];
|
|
493
|
+
}
|
|
494
|
+
/** 十: a question cancelled by Ctrl+C — NEVER the empty string, which is a
|
|
495
|
+
* real user answer (the empty line). The empty answer and the cancellation
|
|
496
|
+
* are distinct facts. */
|
|
497
|
+
const CANCELLED = Symbol("kiso-question-cancelled");
|
|
498
|
+
/**
|
|
499
|
+
* Ask the human a question. Non-interactive stdin (piped, CI) cannot wait
|
|
500
|
+
* forever: approvals auto-deny and uncertain executions auto-abandon, both
|
|
501
|
+
* printed loudly — never silently ignored, never hung (Area 7).
|
|
502
|
+
*
|
|
503
|
+
* 八/十: the question is ABORTABLE — a pending rl.question is registered in
|
|
504
|
+
* `pendingAsk` and the SIGINT handler resolves it with the CANCELLED
|
|
505
|
+
* sentinel. The rl.question callback is NOT left dangling: an input that
|
|
506
|
+
* arrives after the cancellation is re-emitted as a fresh "line" — it
|
|
507
|
+
* becomes the next user turn instead of being swallowed by the dead
|
|
508
|
+
* question.
|
|
509
|
+
*/
|
|
510
|
+
let pendingAsk = null;
|
|
511
|
+
function ask(rl, question) {
|
|
512
|
+
if (!process.stdin.isTTY) {
|
|
513
|
+
console.log(`[non-interactive — no human to ask: ${question}]`);
|
|
514
|
+
return Promise.resolve("");
|
|
515
|
+
}
|
|
516
|
+
// v2b: docked — the question takes over the status position, the
|
|
517
|
+
// answer lands at the input line.
|
|
518
|
+
if (dock.active)
|
|
519
|
+
dock.showQuestion(question);
|
|
520
|
+
return new Promise((resolve) => {
|
|
521
|
+
let settled = false;
|
|
522
|
+
pendingAsk = () => {
|
|
523
|
+
if (settled)
|
|
524
|
+
return;
|
|
525
|
+
settled = true;
|
|
526
|
+
pendingAsk = null;
|
|
527
|
+
resolve(CANCELLED); // the run is aborting — the question is dead
|
|
528
|
+
};
|
|
529
|
+
rl.question(dock.active ? "" : question, (answer) => {
|
|
530
|
+
if (settled) {
|
|
531
|
+
// The question was cancelled; this line is a NEW user turn.
|
|
532
|
+
rl.emit("line", answer);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
settled = true;
|
|
536
|
+
pendingAsk = null;
|
|
537
|
+
if (dock.active)
|
|
538
|
+
dock.clearQuestion();
|
|
539
|
+
resolve(answer);
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* C 区: the model window in tokens — KISO_CONTEXT_WINDOW overrides the
|
|
545
|
+
* 200k default. The microcompact threshold is derived from it (50%), and
|
|
546
|
+
* the status line's ~ctx estimate is measured against it — one source of
|
|
547
|
+
* truth for the window.
|
|
548
|
+
*/
|
|
549
|
+
function contextWindowTokens() {
|
|
550
|
+
const window = Number.parseInt(process.env.KISO_CONTEXT_WINDOW ?? "", 10);
|
|
551
|
+
return Number.isFinite(window) && window > 0 ? window : DEFAULT_CONTEXT_WINDOW;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* B 区: approximate context ratio — chars/4 of the projected messages vs
|
|
555
|
+
* the model window. Marked ~ everywhere it is shown; no counting API.
|
|
556
|
+
*/
|
|
557
|
+
function estimateCtxRatio(session) {
|
|
558
|
+
const projected = session.projected();
|
|
559
|
+
const chars = JSON.stringify(projected).length;
|
|
560
|
+
return chars / 4 / contextWindowTokens();
|
|
561
|
+
}
|
|
562
|
+
/** Decide every uncertain execution with the human (r)erun/(a)bandon. */
|
|
563
|
+
async function resolveUncertains(session, rl, isCancelled) {
|
|
564
|
+
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: `);
|
|
566
|
+
if (isCancelled() || answer === CANCELLED) {
|
|
567
|
+
// 十: a cancellation NEVER records a verdict — the execution
|
|
568
|
+
// stays uncertain and durable; no rerun/abandoned is fabricated.
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const resolution = answer.trim().toLowerCase().startsWith("r") ? "rerun" : "abandoned";
|
|
572
|
+
await session.resolveUncertain(uncertain.executionId, resolution);
|
|
573
|
+
console.log(` ${resolution}\n`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/** B 区: default context window for the ~ctx estimate (config overridable). */
|
|
577
|
+
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
578
|
+
/**
|
|
579
|
+
* Consume a run, answering approval pauses as they arrive. `resumeMode`
|
|
580
|
+
* marks a session.resume() continuation. v2a: `faux` picks the status
|
|
581
|
+
* line's form; `liveInput` (non-null only in interactive chat) carries the
|
|
582
|
+
* last line THIS process's readline consumed — the double-echo filter.
|
|
583
|
+
*/
|
|
584
|
+
async function consumeRun(session, run, rl, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb) {
|
|
585
|
+
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
|
+
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
|
+
try {
|
|
607
|
+
for await (const ev of run) {
|
|
608
|
+
if (firstEvent) {
|
|
609
|
+
firstEvent = false;
|
|
610
|
+
stopSpinner();
|
|
611
|
+
}
|
|
612
|
+
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) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
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) {
|
|
641
|
+
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 }));
|
|
644
|
+
}
|
|
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;
|
|
674
|
+
}
|
|
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
|
+
}
|
|
687
|
+
}
|
|
688
|
+
flushThinking();
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
stopSpinner();
|
|
692
|
+
stopRunning?.();
|
|
693
|
+
}
|
|
694
|
+
return last;
|
|
695
|
+
}
|
|
696
|
+
/** 十: a faux-mode run whose scripted turns are exhausted must NOT print a
|
|
697
|
+
* provider error and exit 0 — the honest outcome is a loud message and a
|
|
698
|
+
* non-zero exit. Thrown as a CONTROLLED exception (never process.exit):
|
|
699
|
+
* the REPL closes, the error propagates through main's finally (so
|
|
700
|
+
* agent.close() runs and no lock is left behind), and main's catch sets
|
|
701
|
+
* the exit code. Only the exhaustion signature (the empty stream after
|
|
702
|
+
* the declared turns) triggers the script-specific message; any other
|
|
703
|
+
* error terminal still exits non-zero. */
|
|
704
|
+
class FauxExhaustionError extends Error {
|
|
705
|
+
constructor(message) {
|
|
706
|
+
super(message);
|
|
707
|
+
this.name = "FauxExhaustionError";
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
function failOnFauxExhaustion(last, faux, rl) {
|
|
711
|
+
if (!faux)
|
|
712
|
+
return;
|
|
713
|
+
if (last?.type !== "terminal" || last.outcome.kind !== "error")
|
|
714
|
+
return;
|
|
715
|
+
const message = last.outcome.error.message;
|
|
716
|
+
rl?.close(); // the REPL must not stay open waiting for a line
|
|
717
|
+
throw new FauxExhaustionError(message.startsWith("provider stream ended without a stop event")
|
|
718
|
+
? "[faux mode] the scripted demo turns are exhausted — set ANTHROPIC_API_KEY or OPENAI_API_KEY for a real model"
|
|
719
|
+
: `[faux mode] the scripted model failed: ${escapeTerminal(message.slice(0, 200))}`);
|
|
720
|
+
}
|
|
721
|
+
/** 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 });
|
|
724
|
+
let currentRun = null;
|
|
725
|
+
let cancelled = false;
|
|
726
|
+
const turn = (input) => new Promise((resolve, reject) => {
|
|
727
|
+
// v2a: the echo filter compares the user_input event against THIS
|
|
728
|
+
// turn's own input — lines that arrive ahead of their turn (piped
|
|
729
|
+
// bursts, queued replays) must not overwrite the reference.
|
|
730
|
+
liveInput.current = input;
|
|
731
|
+
const run = session.run(input);
|
|
732
|
+
currentRun = run;
|
|
733
|
+
turnNo += 1;
|
|
734
|
+
const myTurn = turnNo;
|
|
735
|
+
(async () => {
|
|
736
|
+
let last;
|
|
737
|
+
try {
|
|
738
|
+
last = await consumeRun(session, run, rl, myTurn, lastToolRef, faux, liveInput, lastThinking, statusCb);
|
|
739
|
+
currentRun = null;
|
|
740
|
+
// 八: a faux script that ran out of declared turns exits
|
|
741
|
+
// loudly with a non-zero status — never a silent status 0.
|
|
742
|
+
// 第四轮(对抗): the exhaustion is a CONTROLLED rejection of
|
|
743
|
+
// this turn's promise — it propagates through the chain to
|
|
744
|
+
// chat to main's finally/catch, never an orphaned
|
|
745
|
+
// unhandled rejection from the IIFE.
|
|
746
|
+
failOnFauxExhaustion(last, faux, rl);
|
|
747
|
+
// 八: after EVERY turn the prompt is re-armed — the human
|
|
748
|
+
// never types blind after the first turn.
|
|
749
|
+
rl.setPrompt(interactivePrompt());
|
|
750
|
+
rl.prompt();
|
|
751
|
+
resolve();
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
// A run failure must not freeze the REPL (review finding
|
|
755
|
+
// 11): surface it and re-arm the prompt.
|
|
756
|
+
if (err instanceof FauxExhaustionError) {
|
|
757
|
+
currentRun = null;
|
|
758
|
+
reject(err);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
console.error(`\n[run failed] ${err instanceof Error ? err.message : String(err)}\n`);
|
|
762
|
+
currentRun = null;
|
|
763
|
+
rl.setPrompt(interactivePrompt());
|
|
764
|
+
rl.prompt();
|
|
765
|
+
resolve();
|
|
766
|
+
}
|
|
767
|
+
})();
|
|
768
|
+
});
|
|
769
|
+
rl.on("SIGINT", () => {
|
|
770
|
+
if (currentRun) {
|
|
771
|
+
// 八: Ctrl+C cancels BOTH the pending question (if one is
|
|
772
|
+
// awaiting a line) and the run — the run then writes its unique
|
|
773
|
+
// aborted terminal, which the consumer keeps consuming.
|
|
774
|
+
console.log("\n[aborting run]");
|
|
775
|
+
pendingAsk?.();
|
|
776
|
+
currentRun.abort();
|
|
777
|
+
}
|
|
778
|
+
else if (!cancelled) {
|
|
779
|
+
cancelled = true;
|
|
780
|
+
console.log("\n[exit requested]");
|
|
781
|
+
pendingAsk?.(); // unblock a startup question
|
|
782
|
+
rl.close();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
// 第五轮(P1-11): the PERSISTENT line listener is installed BEFORE the
|
|
786
|
+
// startup recovery — a cancelled question's re-emitted "line" needs a
|
|
787
|
+
// listener from the very first instant, or the input is silently lost.
|
|
788
|
+
// Turns are SERIALIZED on a chain — piped lines arrive faster than
|
|
789
|
+
// turns complete, and concurrent runs are forbidden. Lines that arrive
|
|
790
|
+
// while the recovery is still running are QUEUED and replayed once the
|
|
791
|
+
// REPL is ready (they are never dropped).
|
|
792
|
+
let chain = Promise.resolve();
|
|
793
|
+
let replReady = false;
|
|
794
|
+
const queuedLines = [];
|
|
795
|
+
// B 区: user-turn counter for the status line, and the /last buffer.
|
|
796
|
+
let turnNo = 0;
|
|
797
|
+
const lastToolRef = { current: null };
|
|
798
|
+
// v2a: the last line THIS process's readline consumed — the double-echo
|
|
799
|
+
// filter (see consumeRun). Only interactive chat sets it.
|
|
800
|
+
const liveInput = { current: null };
|
|
801
|
+
// v2b: the last complete thinking block, for /think.
|
|
802
|
+
const lastThinking = { current: null };
|
|
803
|
+
// v2b: the live status bar (docked only).
|
|
804
|
+
const statusCb = (u, ctx) => {
|
|
805
|
+
if (!dock.active)
|
|
806
|
+
return;
|
|
807
|
+
const st = renderStatusLine(turnNo, u, ctx, faux);
|
|
808
|
+
dock.setStatus(st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`);
|
|
809
|
+
};
|
|
810
|
+
dock.bindInput(() => ({ line: rl.line, cursor: rl.cursor }), interactivePrompt());
|
|
811
|
+
rl.on("line", (line) => {
|
|
812
|
+
const trimmed = line.trim();
|
|
813
|
+
if (trimmed === "/help") {
|
|
814
|
+
// Prints the available commands with one-line descriptions.
|
|
815
|
+
// v2a: the command names are the blue identity accent.
|
|
816
|
+
const p = palette();
|
|
817
|
+
const cmd = (name, desc) => `${p.blue}${name}${p.reset} ${desc}`;
|
|
818
|
+
chain = chain.then(async () => {
|
|
819
|
+
bodyLog(cmd("/help", "print this list of commands"));
|
|
820
|
+
bodyLog(cmd("/think", "show the last full thinking block"));
|
|
821
|
+
bodyLog(cmd("/last", "show the most recent tool call's input and output"));
|
|
822
|
+
bodyLog(cmd("/status", "show session id, event count, and context estimate"));
|
|
823
|
+
bodyLog(cmd("exit", "leave the session"));
|
|
824
|
+
rl.setPrompt(interactivePrompt());
|
|
825
|
+
rl.prompt();
|
|
826
|
+
});
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (trimmed === "/think") {
|
|
830
|
+
// v2b: print the last COMPLETE thinking block — straight from the
|
|
831
|
+
// event stream, nothing stored separately (the /last pattern).
|
|
832
|
+
chain = chain.then(async () => {
|
|
833
|
+
const t = lastThinking.current;
|
|
834
|
+
if (t === null) {
|
|
835
|
+
bodyLog("[no thinking yet]");
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
bodyLog(escapeTerminal(t));
|
|
839
|
+
}
|
|
840
|
+
rl.setPrompt(interactivePrompt());
|
|
841
|
+
rl.prompt();
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
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.
|
|
849
|
+
chain = chain.then(async () => {
|
|
850
|
+
const tool = lastToolRef.current;
|
|
851
|
+
if (tool === null) {
|
|
852
|
+
bodyLog("[no tool call yet]");
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
bodyLog(`--- ${tool.name} input ---`);
|
|
856
|
+
bodyLog(escapeTerminal(JSON.stringify(tool.input, null, 2)));
|
|
857
|
+
bodyLog(`--- ${tool.name} output${tool.result.isError ? " (error)" : ""} ---`);
|
|
858
|
+
bodyLog(escapeTerminal(tool.result.content));
|
|
859
|
+
}
|
|
860
|
+
rl.setPrompt(interactivePrompt());
|
|
861
|
+
rl.prompt();
|
|
862
|
+
});
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (trimmed === "/status") {
|
|
866
|
+
// B 区: session id, durable event count, and the ~ context
|
|
867
|
+
// estimate — all read straight from the live session, nothing
|
|
868
|
+
// stored separately. Runs on the chain after any in-flight turn.
|
|
869
|
+
chain = chain.then(async () => {
|
|
870
|
+
const ctxRatio = estimateCtxRatio(session);
|
|
871
|
+
const ctx = Number.isFinite(ctxRatio) ? `~${Math.round(ctxRatio * 100)}%` : "~?";
|
|
872
|
+
bodyLog(`session ${session.id}`);
|
|
873
|
+
bodyLog(`${session.log.all.length} events`);
|
|
874
|
+
bodyLog(`ctx ${ctx}`);
|
|
875
|
+
rl.setPrompt(interactivePrompt());
|
|
876
|
+
rl.prompt();
|
|
877
|
+
});
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (trimmed === "exit" || trimmed === "") {
|
|
881
|
+
rl.close();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (!replReady) {
|
|
885
|
+
queuedLines.push(line);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
chain = chain.then(() => turn(line));
|
|
889
|
+
});
|
|
890
|
+
// Recovery first: a session with a dangling pause or uncertain
|
|
891
|
+
// executions must resolve them BEFORE the REPL accepts new turns —
|
|
892
|
+
// otherwise the interrupted run dangles while a new one starts.
|
|
893
|
+
// 八: the startup resume is bound to currentRun — Ctrl+C during it
|
|
894
|
+
// aborts the recovery, exactly like the interactive turns.
|
|
895
|
+
await resolveUncertains(session, rl, () => cancelled);
|
|
896
|
+
if (!cancelled) {
|
|
897
|
+
const recoveryRun = session.resume();
|
|
898
|
+
currentRun = recoveryRun;
|
|
899
|
+
turnNo += 1;
|
|
900
|
+
const last = await consumeRun(session, recoveryRun, rl, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb);
|
|
901
|
+
currentRun = null;
|
|
902
|
+
failOnFauxExhaustion(last, faux, rl);
|
|
903
|
+
}
|
|
904
|
+
if (cancelled) {
|
|
905
|
+
rl.close();
|
|
906
|
+
await new Promise((resolve) => rl.on("close", () => resolve()));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
// The REPL is ready: replay anything that arrived during recovery.
|
|
910
|
+
replReady = true;
|
|
911
|
+
for (const line of queuedLines) {
|
|
912
|
+
chain = chain.then(() => turn(line));
|
|
913
|
+
}
|
|
914
|
+
queuedLines.length = 0;
|
|
915
|
+
rl.setPrompt(interactivePrompt());
|
|
916
|
+
rl.prompt();
|
|
917
|
+
await new Promise((resolve) => rl.on("close", () => resolve()));
|
|
918
|
+
await chain; // never exit while a turn is in flight
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Resume = the RECOVERY flow (Area 2/7): uncertain executions are decided,
|
|
922
|
+
* the interrupted run is continued via session.resume() — never faked with
|
|
923
|
+
* a new prompt. An optional prompt afterwards starts a genuinely new turn.
|
|
924
|
+
* E 组: SIGINT aborts the run being resumed; every exit path closes the
|
|
925
|
+
* session store so no lock is left behind.
|
|
926
|
+
*/
|
|
927
|
+
async function resume(session, prompt, faux) {
|
|
928
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
929
|
+
let currentRun = null;
|
|
930
|
+
let cancelled = false;
|
|
931
|
+
let turnNo = 0;
|
|
932
|
+
const lastToolRef = { current: null };
|
|
933
|
+
const lastThinking = { current: null };
|
|
934
|
+
// v2b: the live status bar (docked only).
|
|
935
|
+
const statusCb = (u, ctx) => {
|
|
936
|
+
if (!dock.active)
|
|
937
|
+
return;
|
|
938
|
+
const st = renderStatusLine(turnNo, u, ctx, faux);
|
|
939
|
+
dock.setStatus(st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`);
|
|
940
|
+
};
|
|
941
|
+
dock.bindInput(() => ({ line: rl.line, cursor: rl.cursor }), interactivePrompt());
|
|
942
|
+
const withRun = async (run) => {
|
|
943
|
+
currentRun = run;
|
|
944
|
+
try {
|
|
945
|
+
turnNo += 1;
|
|
946
|
+
const last = await consumeRun(session, run, rl, turnNo, lastToolRef, faux, null, lastThinking, statusCb);
|
|
947
|
+
failOnFauxExhaustion(last, faux, rl);
|
|
948
|
+
}
|
|
949
|
+
finally {
|
|
950
|
+
currentRun = null;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
rl.on("SIGINT", () => {
|
|
954
|
+
if (currentRun) {
|
|
955
|
+
// 八: Ctrl+C cancels the pending question AND the run.
|
|
956
|
+
console.log("\n[aborting run]");
|
|
957
|
+
pendingAsk?.();
|
|
958
|
+
currentRun.abort();
|
|
959
|
+
}
|
|
960
|
+
else if (!cancelled) {
|
|
961
|
+
// 第四轮(对抗): also unblock a pending startup question — the
|
|
962
|
+
// readline close alone would leave ask() hanging forever.
|
|
963
|
+
// 第五轮(P2-2): the cancellation is recorded so the recovery is
|
|
964
|
+
// NOT started afterwards — Ctrl+C exits cleanly.
|
|
965
|
+
cancelled = true;
|
|
966
|
+
console.log("\n[exit requested]");
|
|
967
|
+
pendingAsk?.();
|
|
968
|
+
rl.close();
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
try {
|
|
972
|
+
await resolveUncertains(session, rl, () => cancelled);
|
|
973
|
+
if (!cancelled) {
|
|
974
|
+
await withRun(session.resume());
|
|
975
|
+
if (prompt !== undefined && prompt !== "") {
|
|
976
|
+
await withRun(session.run(prompt));
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
finally {
|
|
981
|
+
rl.close();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
async function main() {
|
|
985
|
+
const [command, arg] = process.argv.slice(2);
|
|
986
|
+
// 八: faux mode is the keyless demo script — an exhausted script must
|
|
987
|
+
// exit non-zero, never masquerade as a successful provider run.
|
|
988
|
+
const faux = process.env.ANTHROPIC_API_KEY === undefined && process.env.OPENAI_API_KEY === undefined;
|
|
989
|
+
let agent;
|
|
990
|
+
try {
|
|
991
|
+
switch (command) {
|
|
992
|
+
case "chat": {
|
|
993
|
+
const id = arg ?? new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16);
|
|
994
|
+
// v2b: the dock (TTY only) wraps the whole session — the
|
|
995
|
+
// trust question, the banner, the body, and the input line.
|
|
996
|
+
dock.enter();
|
|
997
|
+
// E 区: a resumed session continues the script at its durable
|
|
998
|
+
// position — never restarts it (fauxSkip).
|
|
999
|
+
const agent = await makeAgent(fauxSkip(id));
|
|
1000
|
+
const session = await agent.session({ id });
|
|
1001
|
+
bodyLog(`session ${id}\n`);
|
|
1002
|
+
extensionsBanner();
|
|
1003
|
+
await chat(session, faux);
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
case "resume": {
|
|
1007
|
+
if (!arg) {
|
|
1008
|
+
console.error("usage: kiso resume <sessionId> [\"prompt\"]");
|
|
1009
|
+
process.exit(2);
|
|
1010
|
+
}
|
|
1011
|
+
// argv[4] is the optional prompt; argv[3] is the session id
|
|
1012
|
+
// (argv = [node, script, resume, id, prompt?]).
|
|
1013
|
+
const prompt = process.argv[4];
|
|
1014
|
+
dock.enter();
|
|
1015
|
+
const agent = await makeAgent(fauxSkip(arg));
|
|
1016
|
+
const session = await agent.session({ id: arg });
|
|
1017
|
+
await resume(session, prompt, faux);
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
case "sessions": {
|
|
1021
|
+
const agent = await makeAgent();
|
|
1022
|
+
for (const meta of agent.sessions()) {
|
|
1023
|
+
console.log(renderSessionLine(meta));
|
|
1024
|
+
}
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
case "help": {
|
|
1028
|
+
const p = palette();
|
|
1029
|
+
console.log(`${p.dim}${LOGO_TOP}${p.blue}${TAGLINE}${p.reset}${p.dim}${LOGO_BOTTOM}${p.reset}\n\n` +
|
|
1030
|
+
"kiso — the coding agent that survives kill -9\n\n" +
|
|
1031
|
+
" kiso [sessionId] interactive session (default command)\n" +
|
|
1032
|
+
" kiso chat [sessionId] same as above\n" +
|
|
1033
|
+
" kiso resume <id> [prompt] continue a session (one-shot)\n" +
|
|
1034
|
+
" kiso sessions list durable sessions\n" +
|
|
1035
|
+
" kiso help this help\n");
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
case undefined:
|
|
1039
|
+
default: {
|
|
1040
|
+
// A 区: no subcommand (or any non-command first argument) IS
|
|
1041
|
+
// chat — the first argument is the session id.
|
|
1042
|
+
const id = command ?? new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16);
|
|
1043
|
+
dock.enter();
|
|
1044
|
+
const agent = await makeAgent(fauxSkip(id));
|
|
1045
|
+
const session = await agent.session({ id });
|
|
1046
|
+
bodyLog(`session ${id}\n`);
|
|
1047
|
+
extensionsBanner();
|
|
1048
|
+
await chat(session, faux);
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
finally {
|
|
1054
|
+
// E 组: every normal and abnormal exit releases the fds and writer
|
|
1055
|
+
// locks — no lock file is left behind.
|
|
1056
|
+
agent?.close();
|
|
1057
|
+
// v2b: the dock tears down on EVERY exit path — CSI r resets the
|
|
1058
|
+
// scroll region, the cursor lands at the input line, no broken
|
|
1059
|
+
// terminal (kill -9 excepted; `reset` saves it).
|
|
1060
|
+
dock.exit();
|
|
1061
|
+
// 发现#8 (P1): extension dispose runs on the same exit path — a
|
|
1062
|
+
// dispose failure prints one line and NEVER changes the exit code.
|
|
1063
|
+
await disposeExtensions(loadedExtensions);
|
|
1064
|
+
// E3: the merged mcp/skills temp artifacts are best-effort removed on
|
|
1065
|
+
// the same exit path — a cleanup failure is silent (tmpdir reaps).
|
|
1066
|
+
for (const p of mergedTempPaths) {
|
|
1067
|
+
try {
|
|
1068
|
+
rmSync(p, { recursive: true, force: true });
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
// best-effort — the temp dir would be reaped by the OS
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
main()
|
|
1077
|
+
.then(() => process.exit(0))
|
|
1078
|
+
.catch((err) => {
|
|
1079
|
+
// 十: top-level errors are terminal-escaped. v2a: the exit is EXPLICIT
|
|
1080
|
+
// — natural drain is racy on a TTY (readline leaves the stdio handles
|
|
1081
|
+
// active and the loop sometimes never drains). main's finally already
|
|
1082
|
+
// ran (agent.close, dispose, temp cleanup) — nothing is skipped, no
|
|
1083
|
+
// lock is left behind; the exit code is honest.
|
|
1084
|
+
console.error(escapeTerminal(err instanceof Error ? err.message : String(err)));
|
|
1085
|
+
process.exit(1);
|
|
1086
|
+
});
|