@vincemakes/kiso-code 0.1.20 → 0.1.22
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/chat.d.ts +49 -0
- package/dist/chat.js +483 -0
- package/dist/dispatch.d.ts +30 -0
- package/dist/dispatch.js +138 -0
- package/dist/faux-glue.d.ts +44 -0
- package/dist/faux-glue.js +115 -0
- package/dist/index.d.ts +8 -8
- package/dist/index.js +31 -985
- package/dist/resume.d.ts +14 -0
- package/dist/resume.js +104 -0
- package/dist/state.d.ts +71 -0
- package/dist/state.js +78 -0
- package/dist/trust-ui.d.ts +50 -0
- package/dist/trust-ui.js +218 -0
- package/package.json +8 -8
package/dist/resume.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手感批 B4 (pure move) — resume: the RECOVERY flow (Area 2/7). The body
|
|
3
|
+
* moved verbatim from index.ts.
|
|
4
|
+
*/
|
|
5
|
+
import type { AgentSession } from "@vincemakes/kiso-runtime";
|
|
6
|
+
import { type LineInput } from "./state.js";
|
|
7
|
+
/**
|
|
8
|
+
* Resume = the RECOVERY flow (Area 2/7): uncertain executions are decided,
|
|
9
|
+
* the interrupted run is continued via session.resume() — never faked with
|
|
10
|
+
* a new prompt. An optional prompt afterwards starts a genuinely new turn.
|
|
11
|
+
* E 组: SIGINT aborts the run being resumed; every exit path closes the
|
|
12
|
+
* session store so no lock is left behind.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resume(session: AgentSession, prompt: string | undefined, faux: boolean, input: LineInput): Promise<void>;
|
package/dist/resume.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手感批 B4 (pure move) — resume: the RECOVERY flow (Area 2/7). The body
|
|
3
|
+
* moved verbatim from index.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { kUnit } from "@vincemakes/kiso-tui";
|
|
6
|
+
import { getMode } from "./mode.js";
|
|
7
|
+
import { agentModel, dock } from "./state.js";
|
|
8
|
+
import { pendingAsk, resolveUncertains } from "./trust-ui.js";
|
|
9
|
+
import { failOnFauxExhaustion } from "./faux-glue.js";
|
|
10
|
+
import { consumeRun, estimateCtxRatio, startStatusSpinner } from "./chat.js";
|
|
11
|
+
/**
|
|
12
|
+
* Resume = the RECOVERY flow (Area 2/7): uncertain executions are decided,
|
|
13
|
+
* the interrupted run is continued via session.resume() — never faked with
|
|
14
|
+
* a new prompt. An optional prompt afterwards starts a genuinely new turn.
|
|
15
|
+
* E 组: SIGINT aborts the run being resumed; every exit path closes the
|
|
16
|
+
* session store so no lock is left behind.
|
|
17
|
+
*/
|
|
18
|
+
export async function resume(session, prompt, faux, input) {
|
|
19
|
+
let currentRun = null;
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
let turnNo = 0;
|
|
22
|
+
// v3 §03: the two-state status bar (see chat — same shapes).
|
|
23
|
+
let runUsage = { in: null, out: null, cache: null, known: false };
|
|
24
|
+
let runGlyph = "▖";
|
|
25
|
+
let runStart = Date.now();
|
|
26
|
+
const statusCb = (u, ctx) => {
|
|
27
|
+
runUsage = u;
|
|
28
|
+
if (!dock.active)
|
|
29
|
+
return;
|
|
30
|
+
const pct = Number.isFinite(ctx) ? Math.round((1 - ctx) * 100) : null;
|
|
31
|
+
const out = runUsage.out !== null ? ` ↓ ${kUnit(runUsage.out)} tokens` : "";
|
|
32
|
+
dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
|
|
33
|
+
};
|
|
34
|
+
const paintIdle = () => {
|
|
35
|
+
if (!dock.active)
|
|
36
|
+
return;
|
|
37
|
+
const ratio = estimateCtxRatio(session);
|
|
38
|
+
const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
|
|
39
|
+
dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
|
|
40
|
+
};
|
|
41
|
+
const withRun = async (run) => {
|
|
42
|
+
currentRun = run;
|
|
43
|
+
runStart = Date.now();
|
|
44
|
+
runUsage = { in: null, out: null, cache: null, known: false };
|
|
45
|
+
const stopSpinner = startStatusSpinner((g) => {
|
|
46
|
+
runGlyph = g;
|
|
47
|
+
statusCb(runUsage, estimateCtxRatio(session));
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
turnNo += 1;
|
|
51
|
+
const last = await consumeRun(session, run, input, turnNo, faux, null, statusCb);
|
|
52
|
+
failOnFauxExhaustion(last, faux, input);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
stopSpinner();
|
|
56
|
+
paintIdle();
|
|
57
|
+
currentRun = null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
input.onSigint(() => {
|
|
61
|
+
if (currentRun) {
|
|
62
|
+
// 八: Ctrl+C cancels the pending question AND the run.
|
|
63
|
+
console.log("\n[aborting run]");
|
|
64
|
+
pendingAsk?.();
|
|
65
|
+
currentRun.abort();
|
|
66
|
+
}
|
|
67
|
+
else if (!cancelled) {
|
|
68
|
+
// 第四轮(对抗): also unblock a pending startup question — the
|
|
69
|
+
// readline close alone would leave ask() hanging forever.
|
|
70
|
+
// 第五轮(P2-2): the cancellation is recorded so the recovery is
|
|
71
|
+
// NOT started afterwards — Ctrl+C exits cleanly.
|
|
72
|
+
cancelled = true;
|
|
73
|
+
console.log("\n[exit requested]");
|
|
74
|
+
pendingAsk?.();
|
|
75
|
+
input.close();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
input.onEot(() => {
|
|
79
|
+
if (!currentRun && !cancelled && input.line() === "") {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
console.log("\n[exit requested]");
|
|
82
|
+
input.close();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
input.onEscape(() => {
|
|
86
|
+
if (currentRun) {
|
|
87
|
+
console.log("\n[aborting run]");
|
|
88
|
+
pendingAsk?.();
|
|
89
|
+
currentRun.abort();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
await resolveUncertains(session, input, () => cancelled);
|
|
94
|
+
if (!cancelled) {
|
|
95
|
+
await withRun(session.resume());
|
|
96
|
+
if (prompt !== undefined && prompt !== "") {
|
|
97
|
+
await withRun(session.run(prompt));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
input.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手感批 B4 — the CLI's shared process state. The module split (dispatch/
|
|
3
|
+
* chat/resume/trust-ui/faux-glue) is a PURE MOVE: every piece that more
|
|
4
|
+
* than one module touches lives here as a live ESM binding. index.ts
|
|
5
|
+
* creates the mutable ones (setBody / setAgentModel / setExtensionLists);
|
|
6
|
+
* the moved modules read and mutate at call time.
|
|
7
|
+
*/
|
|
8
|
+
import { Dock, type Body } from "@vincemakes/kiso-tui";
|
|
9
|
+
import type { KisoExtension } from "@vincemakes/kiso-runtime";
|
|
10
|
+
/** 发现#11: KISO_HOME is the ONE root — every default path derives from
|
|
11
|
+
* it (sessions, trust, extensions, mcp config, skills). The dedicated
|
|
12
|
+
* env vars (KISO_EXTENSIONS_DIR / KISO_MCP_CONFIG / KISO_SKILLS_DIR)
|
|
13
|
+
* still override their own path; nothing hard-codes ~/.kiso anymore. */
|
|
14
|
+
export declare function kisoHome(): string;
|
|
15
|
+
export declare function sessionsDir(): string;
|
|
16
|
+
/** E1: the extension scan directory — KISO_EXTENSIONS_DIR overrides. */
|
|
17
|
+
export declare function extensionsDir(): string;
|
|
18
|
+
/**
|
|
19
|
+
* v2c — the interactive input source. TTYs use the raw-mode Editor (the
|
|
20
|
+
* self-drawn input row — width-aware, the CJK-drift root cause retired,
|
|
21
|
+
* editor.ts); everything else keeps readline exactly as v2b (pipe bytes
|
|
22
|
+
* unchanged). ask()/chat()/resume() talk to this, never to a concrete
|
|
23
|
+
* source.
|
|
24
|
+
*/
|
|
25
|
+
export interface LineInput {
|
|
26
|
+
onLine(cb: (line: string) => void): void;
|
|
27
|
+
onSigint(cb: () => void): void;
|
|
28
|
+
onEot(cb: () => void): void;
|
|
29
|
+
onEscape(cb: () => void): void;
|
|
30
|
+
question(query: string, cb: (answer: string) => void): void;
|
|
31
|
+
cancelQuestion(): void;
|
|
32
|
+
emitLine(line: string): void;
|
|
33
|
+
line(): string;
|
|
34
|
+
clearLine(): void;
|
|
35
|
+
prompt(): void;
|
|
36
|
+
close(): void;
|
|
37
|
+
readonly closed: Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/** v2b: the bottom-anchored UI — docked only on a color TTY; pipes and
|
|
40
|
+
* NO_COLOR stay the v2a line mode byte-for-byte. Created at load, like
|
|
41
|
+
* the pre-split module-scope const. */
|
|
42
|
+
export declare const dock: Dock;
|
|
43
|
+
/** v2d: the body renderer — the ONE writer of the stdout scroll region
|
|
44
|
+
* (the frozen area + the active tail). Pipes run it in passthrough (the
|
|
45
|
+
* v2b/v2c line-mode bytes, byte-for-byte). Created in main; closed on
|
|
46
|
+
* every exit path. */
|
|
47
|
+
export declare let body: Body;
|
|
48
|
+
export declare function setBody(value: Body): void;
|
|
49
|
+
/** v2d: body output routes through the cell renderer — the single writer.
|
|
50
|
+
* bodyLog adds the trailing newline; internal newlines are preserved. */
|
|
51
|
+
export declare function bodyLog(text: string): void;
|
|
52
|
+
/** The model name for the status bar — set by makeAgent. */
|
|
53
|
+
export declare let agentModel: string;
|
|
54
|
+
export declare function setAgentModel(value: string): void;
|
|
55
|
+
/** E1: the extensions loaded by makeAgent — their names feed the banner. */
|
|
56
|
+
export declare let loadedExtensions: readonly KisoExtension[];
|
|
57
|
+
/** E1: the USER-level extensions alone — the banner's unmarked part (E3:
|
|
58
|
+
* loadedExtensions later includes the project-level ones too). */
|
|
59
|
+
export declare let userExtensions: readonly KisoExtension[];
|
|
60
|
+
/** E3: the PROJECT-level extensions (loaded after the trust gate) — the
|
|
61
|
+
* banner distinguishes them from the user-level ones. */
|
|
62
|
+
export declare let projectExtensions: readonly KisoExtension[];
|
|
63
|
+
export declare function setExtensionLists(user: readonly KisoExtension[], project: readonly KisoExtension[], loaded: readonly KisoExtension[]): void;
|
|
64
|
+
/** E3: temp artifacts of the mcp/skills merge — removed on exit. */
|
|
65
|
+
export declare const mergedTempPaths: string[];
|
|
66
|
+
/** The CLI's own version — read from the package.json next to the build. */
|
|
67
|
+
export declare const VERSION: string;
|
|
68
|
+
/** 十: a question cancelled by Ctrl+C — NEVER the empty string, which is a
|
|
69
|
+
* real user answer (the empty line). The empty answer and the cancellation
|
|
70
|
+
* are distinct facts. */
|
|
71
|
+
export declare const CANCELLED: unique symbol;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手感批 B4 — the CLI's shared process state. The module split (dispatch/
|
|
3
|
+
* chat/resume/trust-ui/faux-glue) is a PURE MOVE: every piece that more
|
|
4
|
+
* than one module touches lives here as a live ESM binding. index.ts
|
|
5
|
+
* creates the mutable ones (setBody / setAgentModel / setExtensionLists);
|
|
6
|
+
* the moved modules read and mutate at call time.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { Dock } from "@vincemakes/kiso-tui";
|
|
13
|
+
/** 发现#11: KISO_HOME is the ONE root — every default path derives from
|
|
14
|
+
* it (sessions, trust, extensions, mcp config, skills). The dedicated
|
|
15
|
+
* env vars (KISO_EXTENSIONS_DIR / KISO_MCP_CONFIG / KISO_SKILLS_DIR)
|
|
16
|
+
* still override their own path; nothing hard-codes ~/.kiso anymore. */
|
|
17
|
+
export function kisoHome() {
|
|
18
|
+
return process.env.KISO_HOME ?? join(homedir(), ".kiso");
|
|
19
|
+
}
|
|
20
|
+
export function sessionsDir() {
|
|
21
|
+
return join(kisoHome(), "sessions");
|
|
22
|
+
}
|
|
23
|
+
/** E1: the extension scan directory — KISO_EXTENSIONS_DIR overrides. */
|
|
24
|
+
export function extensionsDir() {
|
|
25
|
+
return process.env.KISO_EXTENSIONS_DIR ?? join(kisoHome(), "extensions");
|
|
26
|
+
}
|
|
27
|
+
/** v2b: the bottom-anchored UI — docked only on a color TTY; pipes and
|
|
28
|
+
* NO_COLOR stay the v2a line mode byte-for-byte. Created at load, like
|
|
29
|
+
* the pre-split module-scope const. */
|
|
30
|
+
export const dock = new Dock();
|
|
31
|
+
/** v2d: the body renderer — the ONE writer of the stdout scroll region
|
|
32
|
+
* (the frozen area + the active tail). Pipes run it in passthrough (the
|
|
33
|
+
* v2b/v2c line-mode bytes, byte-for-byte). Created in main; closed on
|
|
34
|
+
* every exit path. */
|
|
35
|
+
export let body;
|
|
36
|
+
export function setBody(value) {
|
|
37
|
+
body = value;
|
|
38
|
+
}
|
|
39
|
+
/** v2d: body output routes through the cell renderer — the single writer.
|
|
40
|
+
* bodyLog adds the trailing newline; internal newlines are preserved. */
|
|
41
|
+
export function bodyLog(text) {
|
|
42
|
+
body.raw(text.split("\n"));
|
|
43
|
+
}
|
|
44
|
+
/** The model name for the status bar — set by makeAgent. */
|
|
45
|
+
export let agentModel = "faux";
|
|
46
|
+
export function setAgentModel(value) {
|
|
47
|
+
agentModel = value;
|
|
48
|
+
}
|
|
49
|
+
/** E1: the extensions loaded by makeAgent — their names feed the banner. */
|
|
50
|
+
export let loadedExtensions = [];
|
|
51
|
+
/** E1: the USER-level extensions alone — the banner's unmarked part (E3:
|
|
52
|
+
* loadedExtensions later includes the project-level ones too). */
|
|
53
|
+
export let userExtensions = [];
|
|
54
|
+
/** E3: the PROJECT-level extensions (loaded after the trust gate) — the
|
|
55
|
+
* banner distinguishes them from the user-level ones. */
|
|
56
|
+
export let projectExtensions = [];
|
|
57
|
+
export function setExtensionLists(user, project, loaded) {
|
|
58
|
+
userExtensions = user;
|
|
59
|
+
projectExtensions = project;
|
|
60
|
+
loadedExtensions = loaded;
|
|
61
|
+
}
|
|
62
|
+
/** E3: temp artifacts of the mcp/skills merge — removed on exit. */
|
|
63
|
+
export const mergedTempPaths = [];
|
|
64
|
+
/** The CLI's own version — read from the package.json next to the build. */
|
|
65
|
+
export const VERSION = (() => {
|
|
66
|
+
try {
|
|
67
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
|
|
68
|
+
return pkg.version ?? "?";
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// a packed CLI without a readable package.json still works
|
|
72
|
+
return "?";
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
/** 十: a question cancelled by Ctrl+C — NEVER the empty string, which is a
|
|
76
|
+
* real user answer (the empty line). The empty answer and the cancellation
|
|
77
|
+
* are distinct facts. */
|
|
78
|
+
export const CANCELLED = Symbol("kiso-question-cancelled");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手感批 B4 (pure move) — the human-facing question UI: the E3 project
|
|
3
|
+
* trust gate (ADR-0037), the mcp/skills env merges, the generic ask()
|
|
4
|
+
* (approvals, trust, uncertain resolutions), and the uncertain-execution
|
|
5
|
+
* decisions. All bodies moved verbatim from index.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { type ProjectArtifacts } from "@vincemakes/kiso-runtime";
|
|
8
|
+
import type { AgentSession } from "@vincemakes/kiso-runtime";
|
|
9
|
+
import { CANCELLED, type LineInput } from "./state.js";
|
|
10
|
+
/** v2a: the interactive prompt — blue, the identity accent. readline owns
|
|
11
|
+
* the echo of what the user types; we own the prompt's color. (v2c: the
|
|
12
|
+
* readline prompt keeps "you> " — the brick ▌ is the dock's row only;
|
|
13
|
+
* pipe bytes must not change.) */
|
|
14
|
+
export declare function interactivePrompt(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Ask the human a question. Non-interactive stdin (piped, CI) cannot wait
|
|
17
|
+
* forever: approvals auto-deny and uncertain executions auto-abandon, both
|
|
18
|
+
* printed loudly — never silently ignored, never hung (Area 7).
|
|
19
|
+
*
|
|
20
|
+
* 八/十: the question is ABORTABLE — a pending rl.question is registered in
|
|
21
|
+
* `pendingAsk` and the SIGINT handler resolves it with the CANCELLED
|
|
22
|
+
* sentinel. The rl.question callback is NOT left dangling: an input that
|
|
23
|
+
* arrives after the cancellation is re-emitted as a fresh "line" — it
|
|
24
|
+
* becomes the next user turn instead of being swallowed by the dead
|
|
25
|
+
* question.
|
|
26
|
+
*/
|
|
27
|
+
export declare let pendingAsk: (() => void) | null;
|
|
28
|
+
export declare function ask(input: LineInput, question: string): Promise<string | typeof CANCELLED>;
|
|
29
|
+
/**
|
|
30
|
+
* E3 — the project-level trust gate (ADR-0037): capability is trusted by
|
|
31
|
+
* content digest, not by directory. Runs BEFORE any extension loads — the
|
|
32
|
+
* mcp/skills merges must be in the env before the user-level extensions are
|
|
33
|
+
* loaded (the mcp factory reads KISO_MCP_CONFIG at load time, the skills
|
|
34
|
+
* extension scans KISO_SKILLS_DIR at load time).
|
|
35
|
+
*
|
|
36
|
+
* Verdicts: granted → load; refused → never load, never re-ask (refused is
|
|
37
|
+
* sticky — re-evaluate by deleting the trust line or changing a file); no
|
|
38
|
+
* record → only a HUMAN may decide, TTY only — non-TTY refuses with one
|
|
39
|
+
* stderr line. Returns the artifacts on grant, null on anything else.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveProjectTrust(input: LineInput): Promise<ProjectArtifacts | null>;
|
|
42
|
+
/**
|
|
43
|
+
* E3 — merge the project's mcp.json and skills into the env BEFORE the
|
|
44
|
+
* extension load. A server name in BOTH configs is a LOUD error (a silent
|
|
45
|
+
* override would be a supply-chain surprise); a skill name in both merges
|
|
46
|
+
* with project-wins and a stderr note. Exported for tests.
|
|
47
|
+
*/
|
|
48
|
+
export declare function applyProjectMerges(artifacts: ProjectArtifacts): void;
|
|
49
|
+
/** Decide every uncertain execution with the human (r)erun/(a)bandon. */
|
|
50
|
+
export declare function resolveUncertains(session: AgentSession, input: LineInput, isCancelled: () => boolean): Promise<void>;
|
package/dist/trust-ui.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手感批 B4 (pure move) — the human-facing question UI: the E3 project
|
|
3
|
+
* trust gate (ADR-0037), the mcp/skills env merges, the generic ask()
|
|
4
|
+
* (approvals, trust, uncertain resolutions), and the uncertain-execution
|
|
5
|
+
* decisions. All bodies moved verbatim from index.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { escapeTerminal, palette } from "@vincemakes/kiso-tui";
|
|
11
|
+
import { projectArtifacts, recordTrust, trustFor } from "@vincemakes/kiso-runtime";
|
|
12
|
+
import { CANCELLED, bodyLog, dock, kisoHome, mergedTempPaths } from "./state.js";
|
|
13
|
+
/** v2a: the interactive prompt — blue, the identity accent. readline owns
|
|
14
|
+
* the echo of what the user types; we own the prompt's color. (v2c: the
|
|
15
|
+
* readline prompt keeps "you> " — the brick ▌ is the dock's row only;
|
|
16
|
+
* pipe bytes must not change.) */
|
|
17
|
+
export function interactivePrompt() {
|
|
18
|
+
const p = palette();
|
|
19
|
+
return `${p.blue}you> ${p.reset}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Ask the human a question. Non-interactive stdin (piped, CI) cannot wait
|
|
23
|
+
* forever: approvals auto-deny and uncertain executions auto-abandon, both
|
|
24
|
+
* printed loudly — never silently ignored, never hung (Area 7).
|
|
25
|
+
*
|
|
26
|
+
* 八/十: the question is ABORTABLE — a pending rl.question is registered in
|
|
27
|
+
* `pendingAsk` and the SIGINT handler resolves it with the CANCELLED
|
|
28
|
+
* sentinel. The rl.question callback is NOT left dangling: an input that
|
|
29
|
+
* arrives after the cancellation is re-emitted as a fresh "line" — it
|
|
30
|
+
* becomes the next user turn instead of being swallowed by the dead
|
|
31
|
+
* question.
|
|
32
|
+
*/
|
|
33
|
+
export let pendingAsk = null;
|
|
34
|
+
export function ask(input, question) {
|
|
35
|
+
if (!process.stdin.isTTY) {
|
|
36
|
+
console.log(`[non-interactive — no human to ask: ${question}]`);
|
|
37
|
+
return Promise.resolve("");
|
|
38
|
+
}
|
|
39
|
+
// v2b: docked — the question takes over the status position, the
|
|
40
|
+
// answer lands at the input line. v2c: a TTY without a dock (rows < 4)
|
|
41
|
+
// prints the question into the body — the editor cannot show it.
|
|
42
|
+
if (dock.active) {
|
|
43
|
+
dock.showQuestion(question);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
bodyLog(question);
|
|
47
|
+
}
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let settled = false;
|
|
50
|
+
pendingAsk = () => {
|
|
51
|
+
if (settled)
|
|
52
|
+
return;
|
|
53
|
+
settled = true;
|
|
54
|
+
pendingAsk = null;
|
|
55
|
+
input.cancelQuestion();
|
|
56
|
+
resolve(CANCELLED); // the run is aborting — the question is dead
|
|
57
|
+
};
|
|
58
|
+
// v2b: docked — the question reads at the input line, whose prompt
|
|
59
|
+
// is the same blue you> (the editor's brick row; the readline path
|
|
60
|
+
// passes the plain question). An empty prompt would start readline
|
|
61
|
+
// at column 1 while the dock renders "you> " — the typed answer
|
|
62
|
+
// would land on the prompt and drift (probe-confirmed).
|
|
63
|
+
input.question(dock.active ? interactivePrompt() : question, (answer) => {
|
|
64
|
+
if (settled) {
|
|
65
|
+
// The question was cancelled; this line is a NEW user turn.
|
|
66
|
+
input.emitLine(answer);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
settled = true;
|
|
70
|
+
pendingAsk = null;
|
|
71
|
+
if (dock.active)
|
|
72
|
+
dock.clearQuestion();
|
|
73
|
+
resolve(answer);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* E3 — the project-level trust gate (ADR-0037): capability is trusted by
|
|
79
|
+
* content digest, not by directory. Runs BEFORE any extension loads — the
|
|
80
|
+
* mcp/skills merges must be in the env before the user-level extensions are
|
|
81
|
+
* loaded (the mcp factory reads KISO_MCP_CONFIG at load time, the skills
|
|
82
|
+
* extension scans KISO_SKILLS_DIR at load time).
|
|
83
|
+
*
|
|
84
|
+
* Verdicts: granted → load; refused → never load, never re-ask (refused is
|
|
85
|
+
* sticky — re-evaluate by deleting the trust line or changing a file); no
|
|
86
|
+
* record → only a HUMAN may decide, TTY only — non-TTY refuses with one
|
|
87
|
+
* stderr line. Returns the artifacts on grant, null on anything else.
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveProjectTrust(input) {
|
|
90
|
+
const artifacts = await projectArtifacts(process.cwd());
|
|
91
|
+
if (artifacts === null)
|
|
92
|
+
return null; // no .kiso artifacts — nothing to gate
|
|
93
|
+
const record = trustFor(artifacts.root, artifacts.digest);
|
|
94
|
+
if (record?.decision === "granted") {
|
|
95
|
+
applyProjectMerges(artifacts);
|
|
96
|
+
return artifacts;
|
|
97
|
+
}
|
|
98
|
+
if (record?.decision === "refused")
|
|
99
|
+
return null; // refused is sticky — no re-ask
|
|
100
|
+
// First discovery — list every artifact (file name + digest short
|
|
101
|
+
// prefix) and ask the human ONCE.
|
|
102
|
+
if (!process.stdin.isTTY) {
|
|
103
|
+
console.error(`[project .kiso] found ${artifacts.files.length} artifact(s) in ${artifacts.root} — not trusted, not loaded (run kiso interactively once to decide)`);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// v2c: the shared input (the editor on a TTY) reads the answer; the
|
|
107
|
+
// dock shows the question at the status position.
|
|
108
|
+
bodyLog(`[project .kiso] ${artifacts.root}`);
|
|
109
|
+
for (const f of artifacts.files) {
|
|
110
|
+
bodyLog(` ${f.path} (${f.digest.slice(0, 6)})`);
|
|
111
|
+
}
|
|
112
|
+
const answer = await ask(input, `trust this project's .kiso? (y/n) `);
|
|
113
|
+
const granted = answer !== CANCELLED && answer.trim().toLowerCase().startsWith("y");
|
|
114
|
+
recordTrust({ root: artifacts.root, digest: artifacts.digest, decision: granted ? "granted" : "refused" });
|
|
115
|
+
if (!granted)
|
|
116
|
+
return null;
|
|
117
|
+
applyProjectMerges(artifacts);
|
|
118
|
+
return artifacts;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* E3 — merge the project's mcp.json and skills into the env BEFORE the
|
|
122
|
+
* extension load. A server name in BOTH configs is a LOUD error (a silent
|
|
123
|
+
* override would be a supply-chain surprise); a skill name in both merges
|
|
124
|
+
* with project-wins and a stderr note. Exported for tests.
|
|
125
|
+
*/
|
|
126
|
+
export function applyProjectMerges(artifacts) {
|
|
127
|
+
if (artifacts.files.some((f) => f.kind === "mcp"))
|
|
128
|
+
applyMcpMerge(artifacts.root);
|
|
129
|
+
if (artifacts.files.some((f) => f.kind === "skill"))
|
|
130
|
+
applySkillsMerge(artifacts.root);
|
|
131
|
+
}
|
|
132
|
+
/** Read an mcp.json with the mcp extension's tolerance: absent/unreadable →
|
|
133
|
+
* {}, present-but-broken → throw (the loader convention). */
|
|
134
|
+
function readMcpConfig(path) {
|
|
135
|
+
let text;
|
|
136
|
+
try {
|
|
137
|
+
text = readFileSync(path, "utf8");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(text);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
throw new Error(`[project .kiso] cannot parse ${path}: ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
150
|
+
throw new Error(`[project .kiso] ${path} must be an object with an mcpServers map`);
|
|
151
|
+
}
|
|
152
|
+
return parsed;
|
|
153
|
+
}
|
|
154
|
+
/** Merge user-level + project-level mcp.json into one temp file and point
|
|
155
|
+
* KISO_MCP_CONFIG at it — the mcp extension reads it at load time. */
|
|
156
|
+
function applyMcpMerge(root) {
|
|
157
|
+
const userPath = process.env.KISO_MCP_CONFIG ?? join(kisoHome(), "mcp.json");
|
|
158
|
+
const user = readMcpConfig(userPath);
|
|
159
|
+
const project = readMcpConfig(join(root, "mcp.json"));
|
|
160
|
+
const userServers = user.mcpServers ?? {};
|
|
161
|
+
const projectServers = project.mcpServers ?? {};
|
|
162
|
+
if (Object.keys(projectServers).length === 0)
|
|
163
|
+
return; // nothing to merge
|
|
164
|
+
for (const name of Object.keys(projectServers)) {
|
|
165
|
+
if (name in userServers) {
|
|
166
|
+
throw new Error(`[project .kiso] mcp server "${name}" exists in both the user-level and the project-level mcp.json`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const merged = { mcpServers: { ...userServers, ...projectServers } };
|
|
170
|
+
const temp = join(tmpdir(), `kiso-mcp-merged-${process.pid}.json`);
|
|
171
|
+
writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
172
|
+
process.env.KISO_MCP_CONFIG = temp;
|
|
173
|
+
mergedTempPaths.push(temp);
|
|
174
|
+
}
|
|
175
|
+
/** Merge user-level + project-level skills into one temp scan dir (project
|
|
176
|
+
* skill dirs symlinked first; a name in both → project wins + a stderr
|
|
177
|
+
* note) and point KISO_SKILLS_DIR at it — the skills extension's existing
|
|
178
|
+
* scan reads it at load time and per read_skill call. */
|
|
179
|
+
function applySkillsMerge(root) {
|
|
180
|
+
const userDir = process.env.KISO_SKILLS_DIR ?? join(kisoHome(), "skills");
|
|
181
|
+
const projectDir = join(root, "skills");
|
|
182
|
+
const merged = mkdtempSync(join(tmpdir(), "kiso-skills-"));
|
|
183
|
+
mergedTempPaths.push(merged);
|
|
184
|
+
for (const dir of readdirSyncSafe(projectDir)) {
|
|
185
|
+
symlinkSync(join(projectDir, dir), join(merged, dir)); // project wins on collision
|
|
186
|
+
}
|
|
187
|
+
for (const dir of readdirSyncSafe(userDir)) {
|
|
188
|
+
const target = join(merged, dir);
|
|
189
|
+
if (existsSync(target)) {
|
|
190
|
+
console.error(`[project .kiso] skill "${dir}" exists in both user and project skills — project wins`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
symlinkSync(join(userDir, dir), target);
|
|
194
|
+
}
|
|
195
|
+
process.env.KISO_SKILLS_DIR = merged;
|
|
196
|
+
}
|
|
197
|
+
function readdirSyncSafe(dir) {
|
|
198
|
+
try {
|
|
199
|
+
return readdirSync(dir);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return []; // no skills dir on either level = nothing to merge
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Decide every uncertain execution with the human (r)erun/(a)bandon. */
|
|
206
|
+
export async function resolveUncertains(session, input, isCancelled) {
|
|
207
|
+
for (const uncertain of session.uncertainExecutions()) {
|
|
208
|
+
const answer = await ask(input, `⚠ interrupted execution: ${escapeTerminal(uncertain.name)} (${uncertain.executionId}) — did it apply? (r)erun / (a)bandon: `);
|
|
209
|
+
if (isCancelled() || answer === CANCELLED) {
|
|
210
|
+
// 十: a cancellation NEVER records a verdict — the execution
|
|
211
|
+
// stays uncertain and durable; no rerun/abandoned is fabricated.
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const resolution = answer.trim().toLowerCase().startsWith("r") ? "rerun" : "abandoned";
|
|
215
|
+
await session.resolveUncertain(uncertain.executionId, resolution);
|
|
216
|
+
console.log(` ${resolution}\n`);
|
|
217
|
+
}
|
|
218
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-code",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "kiso CLI — the coding-agent reference product: kiso chat / kiso resume / kiso sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
"test": "vitest run"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@vincemakes/kiso-core": "0.1.
|
|
22
|
-
"@vincemakes/kiso-evals": "0.1.
|
|
23
|
-
"@vincemakes/kiso-provider-anthropic": "0.1.
|
|
24
|
-
"@vincemakes/kiso-provider-openai": "0.1.
|
|
25
|
-
"@vincemakes/kiso-runtime": "0.1.
|
|
26
|
-
"@vincemakes/kiso-tools-node": "0.1.
|
|
27
|
-
"@vincemakes/kiso-tui": "0.1.
|
|
21
|
+
"@vincemakes/kiso-core": "0.1.21",
|
|
22
|
+
"@vincemakes/kiso-evals": "0.1.21",
|
|
23
|
+
"@vincemakes/kiso-provider-anthropic": "0.1.21",
|
|
24
|
+
"@vincemakes/kiso-provider-openai": "0.1.21",
|
|
25
|
+
"@vincemakes/kiso-runtime": "0.1.21",
|
|
26
|
+
"@vincemakes/kiso-tools-node": "0.1.21",
|
|
27
|
+
"@vincemakes/kiso-tui": "0.1.21"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^26.1.2",
|