@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/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
+ });