kimi-plugin-cc 2.4.4 → 2.5.1

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.
Files changed (119) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +50 -34
  5. package/dist/commands/swarm.js +1 -1
  6. package/dist/dsh/companion.js +174 -0
  7. package/dist/dsh/index.js +156 -0
  8. package/dist/hooks/config-safety.js +3 -2
  9. package/dist/hooks/install-paths.js +2 -2
  10. package/dist/kimi-engine.js +1 -0
  11. package/dist/swarm-activation.js +1 -1
  12. package/dist/version.js +1 -1
  13. package/docs/migration.md +4 -4
  14. package/docs/models.md +5 -1
  15. package/docs/registry-releases.md +20 -17
  16. package/docs/safety.md +28 -15
  17. package/docs/swarm-recovery.md +1 -1
  18. package/docs/upstream-2.4.1-certification.md +54 -0
  19. package/package.json +41 -6
  20. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  21. package/plugins/kimi-codex/dist/commands/swarm.js +1 -1
  22. package/plugins/kimi-codex/dist/dsh/companion.js +174 -0
  23. package/plugins/kimi-codex/dist/dsh/index.js +156 -0
  24. package/plugins/kimi-codex/dist/hooks/config-safety.js +3 -2
  25. package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
  26. package/plugins/kimi-codex/dist/kimi-engine.js +1 -0
  27. package/plugins/kimi-codex/dist/swarm-activation.js +1 -1
  28. package/plugins/kimi-codex/dist/version.js +1 -1
  29. package/plugins/kimi-dsh/LICENSE +201 -0
  30. package/plugins/kimi-dsh/README.md +80 -0
  31. package/plugins/kimi-dsh/cordis.patch.yml +45 -0
  32. package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
  33. package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
  34. package/plugins/kimi-dsh/dist/cli-client.js +876 -0
  35. package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
  36. package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
  37. package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
  38. package/plugins/kimi-dsh/dist/commands/models.js +140 -0
  39. package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
  40. package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
  41. package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
  42. package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
  43. package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
  44. package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
  45. package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
  46. package/plugins/kimi-dsh/dist/commands/result.js +66 -0
  47. package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
  48. package/plugins/kimi-dsh/dist/commands/review.js +220 -0
  49. package/plugins/kimi-dsh/dist/commands/setup.js +1047 -0
  50. package/plugins/kimi-dsh/dist/commands/status.js +27 -0
  51. package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
  52. package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
  53. package/plugins/kimi-dsh/dist/companion.js +97 -0
  54. package/plugins/kimi-dsh/dist/config.js +22 -0
  55. package/plugins/kimi-dsh/dist/dsh/companion.js +174 -0
  56. package/plugins/kimi-dsh/dist/dsh/index.js +156 -0
  57. package/plugins/kimi-dsh/dist/errors.js +40 -0
  58. package/plugins/kimi-dsh/dist/git.js +224 -0
  59. package/plugins/kimi-dsh/dist/help.js +43 -0
  60. package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
  61. package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
  62. package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
  63. package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
  64. package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
  65. package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
  66. package/plugins/kimi-dsh/dist/hooks/managed-block.js +619 -0
  67. package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
  68. package/plugins/kimi-dsh/dist/job-id.js +12 -0
  69. package/plugins/kimi-dsh/dist/job-store.js +659 -0
  70. package/plugins/kimi-dsh/dist/jobs.js +217 -0
  71. package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
  72. package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
  73. package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
  74. package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
  75. package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
  76. package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
  77. package/plugins/kimi-dsh/dist/logging.js +22 -0
  78. package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
  79. package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
  80. package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
  81. package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
  82. package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
  83. package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
  84. package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
  85. package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
  86. package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
  87. package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
  88. package/plugins/kimi-dsh/dist/model-command.js +32 -0
  89. package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
  90. package/plugins/kimi-dsh/dist/package.json +1 -0
  91. package/plugins/kimi-dsh/dist/parsing.js +620 -0
  92. package/plugins/kimi-dsh/dist/paths.js +27 -0
  93. package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
  94. package/plugins/kimi-dsh/dist/process-tree.js +479 -0
  95. package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
  96. package/plugins/kimi-dsh/dist/render.js +177 -0
  97. package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
  98. package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
  99. package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
  100. package/plugins/kimi-dsh/dist/session-title.js +293 -0
  101. package/plugins/kimi-dsh/dist/stream-json.js +346 -0
  102. package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
  103. package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
  104. package/plugins/kimi-dsh/dist/types.js +1 -0
  105. package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
  106. package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
  107. package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
  108. package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
  109. package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
  110. package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
  111. package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
  112. package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
  113. package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
  114. package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
  115. package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
  116. package/plugins/kimi-dsh/dist/version.js +7 -0
  117. package/plugins/kimi-dsh/package.json +48 -0
  118. package/plugins/kimi-dsh/scripts/companion.sh +37 -0
  119. package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
@@ -0,0 +1,7 @@
1
+ export function textResult(text, structuredContent, isError = false) {
2
+ return {
3
+ content: [{ type: "text", text }],
4
+ ...(structuredContent ? { structuredContent } : {}),
5
+ isError,
6
+ };
7
+ }
@@ -0,0 +1,34 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { RuntimeError } from "../errors.js";
4
+ import { resolveRepoIdentity } from "../git.js";
5
+ export async function resolveMcpWorkspace(env, requested) {
6
+ const defaultCandidate = env.KIMI_PLUGIN_CC_MCP_WORKSPACE;
7
+ const candidate = requested ?? defaultCandidate;
8
+ if (!candidate) {
9
+ throw new RuntimeError("MCP_WORKSPACE_REQUIRED", "This MCP server has no default workspace. Supply the absolute workspace directory explicitly; Codex calls normally require it.", "mcp.workspace");
10
+ }
11
+ const cwd = await canonicalDirectory(candidate, "workspace");
12
+ if (defaultCandidate) {
13
+ const defaultCwd = await canonicalDirectory(defaultCandidate, "host workspace");
14
+ const relative = path.relative(defaultCwd, cwd);
15
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
16
+ throw new RuntimeError("MCP_WORKSPACE_OUTSIDE_DEFAULT", `workspace must be the host workspace or one of its descendants, not ${cwd} (host: ${defaultCwd}).`, "mcp.workspace");
17
+ }
18
+ }
19
+ return { cwd, repo: await resolveRepoIdentity(cwd) };
20
+ }
21
+ async function canonicalDirectory(candidate, label) {
22
+ if (!path.isAbsolute(candidate)) {
23
+ throw new RuntimeError("MCP_WORKSPACE_NOT_ABSOLUTE", `${label} must be an absolute existing directory.`, "mcp.workspace");
24
+ }
25
+ try {
26
+ const cwd = await realpath(candidate);
27
+ if (!(await stat(cwd)).isDirectory())
28
+ throw new Error("not a directory");
29
+ return cwd;
30
+ }
31
+ catch (error) {
32
+ throw new RuntimeError("MCP_WORKSPACE_INVALID", `${label} is not an accessible existing directory: ${candidate}`, "mcp.workspace", error instanceof Error ? { cause: error } : undefined);
33
+ }
34
+ }
@@ -0,0 +1,32 @@
1
+ import { runAsk } from "./commands/ask.js";
2
+ import { notImplementedCompanionCommand } from "./commands/not-implemented.js";
3
+ import { runPursue } from "./commands/pursue.js";
4
+ import { runRescue } from "./commands/rescue.js";
5
+ import { runReview } from "./commands/review.js";
6
+ import { runSwarm } from "./commands/swarm.js";
7
+ export async function runModelCommand(argv, context) {
8
+ const [command, ...rest] = argv;
9
+ switch (command) {
10
+ case "review":
11
+ return `${await runReview(rest, context, "review")}\n`;
12
+ case "ask":
13
+ return `${await runAsk(rest, context)}\n`;
14
+ case "task": {
15
+ const [taskType, ...taskArgs] = rest;
16
+ switch (taskType) {
17
+ case "rescue":
18
+ return runRescue(taskArgs, context);
19
+ case "challenge":
20
+ return `${await runReview(taskArgs, context, "challenge")}\n`;
21
+ case "pursue":
22
+ return runPursue(taskArgs, context);
23
+ case "swarm":
24
+ return runSwarm(taskArgs, context);
25
+ default:
26
+ notImplementedCompanionCommand(taskType ? `task ${taskType}` : "task");
27
+ }
28
+ }
29
+ default:
30
+ return undefined;
31
+ }
32
+ }
@@ -0,0 +1,417 @@
1
+ // Native-v2 pre-spawn safety preflight.
2
+ //
3
+ // Why this module exists:
4
+ //
5
+ // kimi-code 0.42.0 removed the legacy-v1 engine. agent-core-v2's ONLY
6
+ // chain-breaking `event.allow()` is the plan-file guard
7
+ // (features/plan/planService.ts:110), and it fires only while plan mode is
8
+ // ACTIVE. Plan mode can arm in a plugin-spawned `kimi -p` session in exactly
9
+ // three ways:
10
+ // A. `default_plan_mode = true` in <KIMI_CODE_HOME>/config.toml, read ONCE
11
+ // inside sessions.create() (single user file; no env, argv or project
12
+ // overlay; invalid values fall back to false upstream);
13
+ // B. the EnterPlanMode tool (already hook-denied for every label);
14
+ // C. resuming a session whose agent wire journal holds a durable
15
+ // `plan_mode.enter` record (restore() folds ONLY agents/*/wire.jsonl).
16
+ // This module closes A and C before any process is spawned, and refuses the
17
+ // v2-only experimental features (tower, subagent fork) that this plugin does
18
+ // not certify. B stays in runtime/hooks/approval-policy.ts.
19
+ //
20
+ // Contract:
21
+ // - Pure inspection: never writes, never rewrites operator config, never
22
+ // clears saved plan state. Refusals name the setting, never dump config.
23
+ // - Fail-closed: unparseable/unreadable/oversized inputs refuse.
24
+ // - Strictly more conservative than upstream (a non-boolean value that
25
+ // upstream would ignore still refuses here).
26
+ // - Refusals here are NOT /kimi:setup-recoverable and must never be
27
+ // described as hook drift.
28
+ import { constants as fsConstants } from "node:fs";
29
+ import { open, readdir } from "node:fs/promises";
30
+ import path from "node:path";
31
+ import { createInterface } from "node:readline";
32
+ import { RuntimeError } from "./errors.js";
33
+ import { parse as parseToml } from "./vendor/smol-toml/parse.js";
34
+ /** `<kimiHome>/config.toml` is small hand-edited config, never a data file. */
35
+ const CONFIG_MAX_BYTES = 1 * 1024 * 1024;
36
+ /** A single agent's wire journal; generous but bounded so a runaway session
37
+ * log can't turn a preflight check into an unbounded read. */
38
+ const JOURNAL_MAX_BYTES = 64 * 1024 * 1024;
39
+ /** Bounded directory enumeration so a pathological sessions/ tree refuses
40
+ * instead of scanning forever. */
41
+ const DIR_ENUM_CAP = 4096;
42
+ const TRUTHY_STRINGS = new Set(["1", "true", "yes", "on"]);
43
+ function isTruthySelectorValue(value) {
44
+ if (value === true)
45
+ return true;
46
+ if (typeof value === "string")
47
+ return TRUTHY_STRINGS.has(value.trim().toLowerCase());
48
+ return false;
49
+ }
50
+ /**
51
+ * Read a small file with the same no-follow, size-capped posture as the
52
+ * config lock reader in runtime/hooks/config-safety.ts. Never partial-reads:
53
+ * an oversized file refuses rather than being silently truncated.
54
+ */
55
+ async function readSmallFileSafe(filePath, maxBytes) {
56
+ let handle;
57
+ try {
58
+ handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
59
+ }
60
+ catch (error) {
61
+ const code = error.code;
62
+ if (code === "ENOENT")
63
+ return { kind: "absent" };
64
+ if (code === "ELOOP")
65
+ return { kind: "unreadable", reason: "path is a symbolic link" };
66
+ return { kind: "unreadable", reason: error.message };
67
+ }
68
+ try {
69
+ const stats = await handle.stat({ bigint: true });
70
+ if (!stats.isFile())
71
+ return { kind: "unreadable", reason: "not a regular file" };
72
+ if (stats.size > BigInt(maxBytes)) {
73
+ return { kind: "unreadable", reason: `exceeds ${maxBytes} byte cap` };
74
+ }
75
+ const size = Number(stats.size);
76
+ const buffer = Buffer.alloc(size);
77
+ let offset = 0;
78
+ while (offset < size) {
79
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
80
+ if (bytesRead === 0)
81
+ break;
82
+ offset += bytesRead;
83
+ }
84
+ return { kind: "ok", contents: buffer.subarray(0, offset).toString("utf8") };
85
+ }
86
+ finally {
87
+ await handle.close().catch(() => undefined);
88
+ }
89
+ }
90
+ /** Read+parse `<kimiHome>/config.toml`. `config: null` means the file is
91
+ * absent (upstream default applies); any read/parse failure fails closed. */
92
+ async function readParsedConfig(kimiHome) {
93
+ const configPath = path.join(kimiHome, "config.toml");
94
+ const read = await readSmallFileSafe(configPath, CONFIG_MAX_BYTES);
95
+ if (read.kind === "absent")
96
+ return { kind: "ok", config: null };
97
+ if (read.kind === "unreadable") {
98
+ return { kind: "unreadable", reason: `${configPath} could not be read safely: ${read.reason}` };
99
+ }
100
+ try {
101
+ return { kind: "ok", config: parseToml(read.contents) };
102
+ }
103
+ catch (error) {
104
+ return {
105
+ kind: "unreadable",
106
+ reason: `${configPath} could not be parsed as TOML: ${error.message}`,
107
+ };
108
+ }
109
+ }
110
+ /**
111
+ * Upstream's EXACT top-level key normalization
112
+ * (`packages/agent-core-v2/src/app/config/toml.ts::snakeToCamel`, applied to
113
+ * every top-level key by `transformTomlData` before the config registry sees
114
+ * it). The plan section is registered under the camelCase domain
115
+ * `defaultPlanMode`, so the operator can spell it `default_plan_mode`,
116
+ * `defaultPlanMode`, `default_planMode`, … and upstream reads the same
117
+ * setting. The plugin MUST inspect the same normalized view — a literal
118
+ * `default_plan_mode` check lets `defaultPlanMode = true` arm plan mode.
119
+ * Pinned by tests/audit/v2-tag-scan.test.ts (toml.ts hash + regex text).
120
+ * NOTE: the `[experimental]` section registers its own `fromToml` that keeps
121
+ * raw keys, and its flag ids are snake_case (`subagent_fork`), so that table
122
+ * is deliberately NOT normalized here.
123
+ */
124
+ export function upstreamSnakeToCamel(key) {
125
+ return key.replace(/_([a-z])/g, (_match, ch) => ch.toUpperCase());
126
+ }
127
+ const DEFAULT_PLAN_MODE_DOMAIN = "defaultPlanMode";
128
+ /**
129
+ * Inspect `<kimiHome>/config.toml` for the upstream default-plan-mode setting
130
+ * under EVERY spelling upstream normalizes to the `defaultPlanMode` domain.
131
+ * ENOENT → ok:"absent" (upstream default is false; the missing hook is caught
132
+ * separately). Any other read error, a symlinked file (open with O_NOFOLLOW),
133
+ * an oversized file, or a TOML parse failure → refuse "v2-config-unreadable".
134
+ * Every matching key holding literal boolean `false` → ok:"false". ANY matching
135
+ * key with any other value (true, string, number, table, array) → refuse
136
+ * "v2-plan-mode-configured", naming the raw key(s) found.
137
+ */
138
+ export async function inspectPlanModeConfig(kimiHome) {
139
+ const parsed = await readParsedConfig(kimiHome);
140
+ if (parsed.kind === "unreadable") {
141
+ return { kind: "refuse", refusalKind: "v2-config-unreadable", reason: parsed.reason };
142
+ }
143
+ if (parsed.config === null)
144
+ return { kind: "ok", setting: "absent" };
145
+ const matching = Object.entries(parsed.config).filter(([rawKey]) => upstreamSnakeToCamel(rawKey) === DEFAULT_PLAN_MODE_DOMAIN);
146
+ if (matching.length === 0)
147
+ return { kind: "ok", setting: "absent" };
148
+ const offending = matching.filter(([, value]) => value !== false).map(([rawKey]) => rawKey);
149
+ if (offending.length === 0)
150
+ return { kind: "ok", setting: "false" };
151
+ const configPath = path.join(kimiHome, "config.toml");
152
+ return {
153
+ kind: "refuse",
154
+ refusalKind: "v2-plan-mode-configured",
155
+ reason: `\`${offending.join("`, `")}\` (upstream setting \`default_plan_mode\`) is set in ${configPath} and is not \`false\`.`,
156
+ };
157
+ }
158
+ /**
159
+ * Refuse v2-only experimental features this plugin does not certify.
160
+ * Sources (per-flag precedence upstream is env → [experimental] config →
161
+ * master flag → default): the `[experimental]` table keys `tower` and
162
+ * `subagent_fork` in `<kimiHome>/config.toml`, and the per-flag env vars
163
+ * `KIMI_CODE_EXPERIMENTAL_TOWER` / `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK`.
164
+ * Truthy = boolean true, or a string in {"1","true","yes","on"} (trimmed,
165
+ * case-insensitive). The master `KIMI_CODE_EXPERIMENTAL_FLAG` is handled by
166
+ * kimi-engine.ts::assertNoUnsafeExperimentalSelector, not here.
167
+ * Config read errors follow inspectPlanModeConfig's fail-closed rules.
168
+ */
169
+ export async function inspectExperimentalSelectors(kimiHome, env) {
170
+ const parsed = await readParsedConfig(kimiHome);
171
+ if (parsed.kind === "unreadable") {
172
+ return { kind: "refuse", selectors: [], reason: parsed.reason };
173
+ }
174
+ const selectors = [];
175
+ const experimental = parsed.config?.["experimental"];
176
+ if (experimental !== null && typeof experimental === "object" && !Array.isArray(experimental)) {
177
+ const table = experimental;
178
+ if (isTruthySelectorValue(table["tower"]))
179
+ selectors.push("config:[experimental].tower");
180
+ if (isTruthySelectorValue(table["subagent_fork"])) {
181
+ selectors.push("config:[experimental].subagent_fork");
182
+ }
183
+ }
184
+ if (isTruthySelectorValue(env.KIMI_CODE_EXPERIMENTAL_TOWER)) {
185
+ selectors.push("env:KIMI_CODE_EXPERIMENTAL_TOWER");
186
+ }
187
+ if (isTruthySelectorValue(env.KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK)) {
188
+ selectors.push("env:KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK");
189
+ }
190
+ if (selectors.length === 0)
191
+ return { kind: "ok" };
192
+ return {
193
+ kind: "refuse",
194
+ selectors,
195
+ reason: `Refusing v2-only experimental feature(s) not certified by kimi-plugin-cc: ${selectors.join(", ")}.`,
196
+ };
197
+ }
198
+ async function boundedReaddir(dirPath, cap) {
199
+ let entries;
200
+ try {
201
+ entries = await readdir(dirPath, { withFileTypes: true });
202
+ }
203
+ catch (error) {
204
+ const code = error.code;
205
+ if (code === "ENOENT" || code === "ENOTDIR")
206
+ return { kind: "absent" };
207
+ // Any other enumeration failure (EACCES, EIO, ELOOP, …) must land in the
208
+ // classified `unavailable` refusal, not escape as an untyped fs error.
209
+ return { kind: "unreadable", reason: `${dirPath}: ${error.message}` };
210
+ }
211
+ if (entries.length > cap)
212
+ return { kind: "cap-exceeded" };
213
+ return { kind: "ok", entries };
214
+ }
215
+ async function openJournalSafely(journalPath, maxBytes) {
216
+ let handle;
217
+ try {
218
+ // O_NONBLOCK so a FIFO planted at the journal path (no writer) does not
219
+ // block the open() indefinitely — the regular-file check below then
220
+ // refuses it. O_NOFOLLOW refuses a symlinked final component.
221
+ handle = await open(journalPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
222
+ }
223
+ catch (error) {
224
+ const code = error.code;
225
+ if (code === "ENOENT")
226
+ return { kind: "unavailable", reason: "journal not found" };
227
+ if (code === "ELOOP")
228
+ return { kind: "unavailable", reason: "journal path is a symbolic link" };
229
+ return { kind: "unavailable", reason: error.message };
230
+ }
231
+ const stats = await handle.stat({ bigint: true });
232
+ if (!stats.isFile()) {
233
+ await handle.close().catch(() => undefined);
234
+ return { kind: "unavailable", reason: "journal is not a regular file" };
235
+ }
236
+ if (stats.size > BigInt(maxBytes)) {
237
+ await handle.close().catch(() => undefined);
238
+ return { kind: "unavailable", reason: `journal exceeds ${maxBytes} byte cap` };
239
+ }
240
+ return { kind: "ok", handle };
241
+ }
242
+ /**
243
+ * Scan one open journal's lines for a plan-mode control record. Returns the
244
+ * record `type` string on a match, "<unparseable>" for a suspicious line
245
+ * that fails to parse, or null when the journal is clean.
246
+ */
247
+ async function scanJournalLines(handle) {
248
+ const rl = createInterface({ input: handle.createReadStream() });
249
+ try {
250
+ for await (const rawLine of rl) {
251
+ const line = rawLine.trim();
252
+ if (line.length === 0)
253
+ continue;
254
+ // Parse EVERY record and check its DECODED `type`. A raw-substring filter
255
+ // is unsafe on its own: a JSON-escaped type such as
256
+ // {"type":"plan_mode.enter"} contains no literal "plan_mode." yet
257
+ // upstream JSON.parse decodes it to "plan_mode.enter" and restores active
258
+ // plan mode. So parse first (this catches the escape). If a line FAILS to
259
+ // parse, upstream cannot fold it into a plan event either — but be
260
+ // conservative: a malformed line that still LOOKS plan-related is treated
261
+ // as tainted rather than skipped, so a hand-corrupted journal cannot hide
262
+ // a plan record behind a parse error.
263
+ let parsed;
264
+ try {
265
+ parsed = JSON.parse(line);
266
+ }
267
+ catch {
268
+ if (line.includes('"plan_mode.') || line.includes('"plan.')) {
269
+ return "<unparseable>";
270
+ }
271
+ continue;
272
+ }
273
+ const type = parsed?.["type"];
274
+ if (typeof type === "string" && (type.startsWith("plan_mode.") || type.startsWith("plan."))) {
275
+ return type;
276
+ }
277
+ }
278
+ return null;
279
+ }
280
+ finally {
281
+ rl.close();
282
+ }
283
+ }
284
+ /**
285
+ * Scan every agent wire journal of a saved session for plan-mode records.
286
+ * Layout at 0.42.0: `<kimiHome>/sessions/<workspaceId>/<sessionId>/agents/<agentId>/wire.jsonl`
287
+ * (workspaceId is unknown to the plugin → enumerate `sessions/*` with bounded
288
+ * readdir). Raw-line scan of EVERY agent journal: a JSON line whose `type`
289
+ * starts with `plan_mode.` or `plan.` → tainted (fail-closed on a line that
290
+ * looks suspicious but does not parse). No session dir, no journal, unreadable,
291
+ * or any single journal above the size cap → unavailable. Never modifies files.
292
+ */
293
+ export async function scanSessionJournalsForPlan(kimiHome, sessionId) {
294
+ const sessionsDir = path.join(kimiHome, "sessions");
295
+ const workspaceEntries = await boundedReaddir(sessionsDir, DIR_ENUM_CAP);
296
+ if (workspaceEntries.kind === "absent") {
297
+ return { kind: "unavailable", reason: `no sessions directory at ${sessionsDir}` };
298
+ }
299
+ if (workspaceEntries.kind === "cap-exceeded") {
300
+ return { kind: "unavailable", reason: `too many entries under ${sessionsDir} to scan safely` };
301
+ }
302
+ if (workspaceEntries.kind === "unreadable") {
303
+ return { kind: "unavailable", reason: `could not enumerate the session tree: ${workspaceEntries.reason}` };
304
+ }
305
+ const journalPaths = [];
306
+ for (const workspaceEntry of workspaceEntries.entries) {
307
+ // A symlinked entry in the session tree is refused, not skipped: a
308
+ // symlinked agent (or workspace) directory has isDirectory() === false, so
309
+ // skipping it would drop its journal from the scan and let an active-plan
310
+ // journal hide behind the symlink while a sibling reads clean. Fail closed.
311
+ if (workspaceEntry.isSymbolicLink()) {
312
+ return {
313
+ kind: "unavailable",
314
+ reason: `symlinked entry in the session tree: ${path.join(sessionsDir, workspaceEntry.name)}`,
315
+ };
316
+ }
317
+ if (!workspaceEntry.isDirectory())
318
+ continue;
319
+ const agentsDir = path.join(sessionsDir, workspaceEntry.name, sessionId, "agents");
320
+ const agentEntries = await boundedReaddir(agentsDir, DIR_ENUM_CAP);
321
+ if (agentEntries.kind === "absent")
322
+ continue;
323
+ if (agentEntries.kind === "cap-exceeded") {
324
+ return { kind: "unavailable", reason: `too many agent entries under ${agentsDir} to scan safely` };
325
+ }
326
+ if (agentEntries.kind === "unreadable") {
327
+ return { kind: "unavailable", reason: `could not enumerate agent journals: ${agentEntries.reason}` };
328
+ }
329
+ for (const agentEntry of agentEntries.entries) {
330
+ if (agentEntry.isSymbolicLink()) {
331
+ return {
332
+ kind: "unavailable",
333
+ reason: `symlinked agent entry in the session tree: ${path.join(agentsDir, agentEntry.name)}`,
334
+ };
335
+ }
336
+ if (!agentEntry.isDirectory())
337
+ continue;
338
+ journalPaths.push(path.join(agentsDir, agentEntry.name, "wire.jsonl"));
339
+ }
340
+ }
341
+ if (journalPaths.length === 0) {
342
+ return { kind: "unavailable", reason: `no agent wire journals found for session ${sessionId}` };
343
+ }
344
+ for (const journalPath of journalPaths) {
345
+ const opened = await openJournalSafely(journalPath, JOURNAL_MAX_BYTES);
346
+ if (opened.kind === "unavailable") {
347
+ return { kind: "unavailable", reason: `${journalPath}: ${opened.reason}` };
348
+ }
349
+ try {
350
+ const taintedType = await scanJournalLines(opened.handle);
351
+ if (taintedType !== null) {
352
+ return { kind: "tainted", journal: journalPath, recordType: taintedType };
353
+ }
354
+ }
355
+ finally {
356
+ await opened.handle.close().catch(() => undefined);
357
+ }
358
+ }
359
+ return { kind: "clean", journals: journalPaths };
360
+ }
361
+ /**
362
+ * Run every inspection and throw a RuntimeError on the first refusal:
363
+ * CLI_V2_PLAN_MODE_CONFIGURED details.refusal_kind = "v2-plan-mode-configured"
364
+ * CLI_V2_CONFIG_UNREADABLE details.refusal_kind = "v2-config-unreadable"
365
+ * CLI_V2_EXPERIMENTAL_UNSAFE details.refusal_kind = "v2-experimental-unsafe", details.selectors
366
+ * KIMI_SESSION_PLAN_TAINTED details.refusal_kind = "session-plan-tainted", details.record_type
367
+ * KIMI_SESSION_JOURNAL_UNAVAILABLE details.refusal_kind = "session-journal-unavailable"
368
+ * Every message carries a truthful remedy (edit the named setting / start a
369
+ * fresh session) and MUST NOT mention /kimi:setup or hook drift.
370
+ * Every details object also carries `retryable_after_setup: false`.
371
+ */
372
+ export async function assertNativeV2Preflight(options) {
373
+ const stage = options.stage ?? "native-v2.preflight";
374
+ const configPath = path.join(options.kimiHome, "config.toml");
375
+ const planMode = await inspectPlanModeConfig(options.kimiHome);
376
+ if (planMode.kind === "refuse") {
377
+ const code = planMode.refusalKind === "v2-plan-mode-configured"
378
+ ? "CLI_V2_PLAN_MODE_CONFIGURED"
379
+ : "CLI_V2_CONFIG_UNREADABLE";
380
+ const remedy = planMode.refusalKind === "v2-plan-mode-configured"
381
+ ? `Set \`default_plan_mode = false\` (or remove it) in ${configPath}.`
382
+ : `Fix or remove the unreadable file at ${configPath} so plan mode can be verified off.`;
383
+ throw new RuntimeError(code, `${planMode.reason} ${remedy}`, stage, {
384
+ details: { refusal_kind: planMode.refusalKind, retryable_after_setup: false },
385
+ });
386
+ }
387
+ const experimental = await inspectExperimentalSelectors(options.kimiHome, options.env);
388
+ if (experimental.kind === "refuse") {
389
+ throw new RuntimeError("CLI_V2_EXPERIMENTAL_UNSAFE", `${experimental.reason} Unset the listed selector(s) before retrying.`, stage, {
390
+ details: {
391
+ refusal_kind: "v2-experimental-unsafe",
392
+ selectors: experimental.selectors,
393
+ retryable_after_setup: false,
394
+ },
395
+ });
396
+ }
397
+ if (options.resumeSessionId !== undefined) {
398
+ const journalScan = await scanSessionJournalsForPlan(options.kimiHome, options.resumeSessionId);
399
+ if (journalScan.kind === "tainted") {
400
+ throw new RuntimeError("KIMI_SESSION_PLAN_TAINTED", `Saved session ${options.resumeSessionId} previously entered plan mode (record type "${journalScan.recordType}" in ${journalScan.journal}). Start a fresh session; saved session state was not modified.`, stage, {
401
+ details: {
402
+ refusal_kind: "session-plan-tainted",
403
+ record_type: journalScan.recordType,
404
+ retryable_after_setup: false,
405
+ },
406
+ });
407
+ }
408
+ if (journalScan.kind === "unavailable") {
409
+ throw new RuntimeError("KIMI_SESSION_JOURNAL_UNAVAILABLE", `Could not verify saved session ${options.resumeSessionId} is free of plan-mode state (${journalScan.reason}). Start a fresh session; saved session state was not modified.`, stage, {
410
+ details: {
411
+ refusal_kind: "session-journal-unavailable",
412
+ retryable_after_setup: false,
413
+ },
414
+ });
415
+ }
416
+ }
417
+ }
@@ -0,0 +1 @@
1
+ {"type":"module"}