kimi-plugin-cc 2.4.5 → 2.5.2

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 (114) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +103 -29
  5. package/dist/commands/setup.js +78 -51
  6. package/dist/dsh/companion.js +177 -0
  7. package/dist/dsh/index.js +169 -0
  8. package/dist/hooks/install-paths.js +2 -2
  9. package/dist/hooks/managed-block.js +86 -20
  10. package/dist/version.js +1 -1
  11. package/docs/migration.md +4 -2
  12. package/docs/models.md +5 -1
  13. package/docs/registry-releases.md +84 -20
  14. package/docs/safety.md +25 -10
  15. package/docs/upstream-2.4.1-certification.md +6 -0
  16. package/package.json +75 -8
  17. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  18. package/plugins/kimi-codex/dist/commands/setup.js +78 -51
  19. package/plugins/kimi-codex/dist/dsh/companion.js +177 -0
  20. package/plugins/kimi-codex/dist/dsh/index.js +169 -0
  21. package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
  22. package/plugins/kimi-codex/dist/hooks/managed-block.js +86 -20
  23. package/plugins/kimi-codex/dist/version.js +1 -1
  24. package/plugins/kimi-dsh/LICENSE +201 -0
  25. package/plugins/kimi-dsh/README.md +83 -0
  26. package/plugins/kimi-dsh/cordis.patch.yml +45 -0
  27. package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
  28. package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
  29. package/plugins/kimi-dsh/dist/cli-client.js +876 -0
  30. package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
  31. package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
  32. package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
  33. package/plugins/kimi-dsh/dist/commands/models.js +140 -0
  34. package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
  35. package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
  36. package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
  37. package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
  38. package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
  39. package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
  40. package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
  41. package/plugins/kimi-dsh/dist/commands/result.js +66 -0
  42. package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
  43. package/plugins/kimi-dsh/dist/commands/review.js +220 -0
  44. package/plugins/kimi-dsh/dist/commands/setup.js +1074 -0
  45. package/plugins/kimi-dsh/dist/commands/status.js +27 -0
  46. package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
  47. package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
  48. package/plugins/kimi-dsh/dist/companion.js +97 -0
  49. package/plugins/kimi-dsh/dist/config.js +22 -0
  50. package/plugins/kimi-dsh/dist/dsh/companion.js +177 -0
  51. package/plugins/kimi-dsh/dist/dsh/index.js +169 -0
  52. package/plugins/kimi-dsh/dist/errors.js +40 -0
  53. package/plugins/kimi-dsh/dist/git.js +224 -0
  54. package/plugins/kimi-dsh/dist/help.js +43 -0
  55. package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
  56. package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
  57. package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
  58. package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
  59. package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
  60. package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
  61. package/plugins/kimi-dsh/dist/hooks/managed-block.js +685 -0
  62. package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
  63. package/plugins/kimi-dsh/dist/job-id.js +12 -0
  64. package/plugins/kimi-dsh/dist/job-store.js +659 -0
  65. package/plugins/kimi-dsh/dist/jobs.js +217 -0
  66. package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
  67. package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
  68. package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
  69. package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
  70. package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
  71. package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
  72. package/plugins/kimi-dsh/dist/logging.js +22 -0
  73. package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
  74. package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
  75. package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
  76. package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
  77. package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
  78. package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
  79. package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
  80. package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
  81. package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
  82. package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
  83. package/plugins/kimi-dsh/dist/model-command.js +32 -0
  84. package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
  85. package/plugins/kimi-dsh/dist/package.json +1 -0
  86. package/plugins/kimi-dsh/dist/parsing.js +620 -0
  87. package/plugins/kimi-dsh/dist/paths.js +27 -0
  88. package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
  89. package/plugins/kimi-dsh/dist/process-tree.js +479 -0
  90. package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
  91. package/plugins/kimi-dsh/dist/render.js +177 -0
  92. package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
  93. package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
  94. package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
  95. package/plugins/kimi-dsh/dist/session-title.js +293 -0
  96. package/plugins/kimi-dsh/dist/stream-json.js +346 -0
  97. package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
  98. package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
  99. package/plugins/kimi-dsh/dist/types.js +1 -0
  100. package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
  101. package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
  102. package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
  103. package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
  104. package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
  105. package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
  106. package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
  107. package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
  108. package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
  109. package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
  110. package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
  111. package/plugins/kimi-dsh/dist/version.js +7 -0
  112. package/plugins/kimi-dsh/package.json +78 -0
  113. package/plugins/kimi-dsh/scripts/companion.sh +37 -0
  114. package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
@@ -0,0 +1,224 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { realpath, rm } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { RuntimeError } from "./errors.js";
7
+ const execFileAsync = promisify(execFile);
8
+ const MAX_DIFF_CHARS = 80_000;
9
+ /**
10
+ * Buffer ceiling for a write-swarm capture patch. Unlike review diffs (capped at
11
+ * MAX_DIFF_CHARS for prompt budget), a capture patch MUST be byte-complete to
12
+ * `git apply`, so it is never truncated — only bounded by this generous buffer to
13
+ * avoid an unbounded RSS spike. A swarm patch larger than this is pathological.
14
+ */
15
+ const MAX_PATCH_BYTES = 64 * 1024 * 1024;
16
+ export async function collectReviewContext(cwd, base) {
17
+ const { repoRoot } = await resolveRepoIdentity(cwd);
18
+ const statusSummary = (await runGit(["status", "--short", "--untracked-files=all"], cwd)).trim();
19
+ if (base) {
20
+ const diffText = await getBaseDiff(cwd, base);
21
+ return {
22
+ repoRoot,
23
+ statusSummary,
24
+ diffText: truncateDiff(diffText),
25
+ diffTruncated: diffText.length > MAX_DIFF_CHARS,
26
+ scope: "base",
27
+ targetDescription: `branch diff against ${base}`,
28
+ };
29
+ }
30
+ // Working-tree review is repo-wide. status --short is already repo-wide; limiting diff to
31
+ // the caller's subdirectory would mask unrelated-but-relevant changes elsewhere in the repo.
32
+ const headDiff = await runGitAllowFailure(["diff", "--no-ext-diff", "--unified=3", "HEAD", "--"], cwd);
33
+ const diffText = headDiff.ok && headDiff.stdout.trim().length > 0
34
+ ? headDiff.stdout
35
+ : await runGit(["diff", "--no-ext-diff", "--unified=3", "--"], cwd);
36
+ return {
37
+ repoRoot,
38
+ statusSummary,
39
+ diffText: truncateDiff(diffText),
40
+ diffTruncated: diffText.length > MAX_DIFF_CHARS,
41
+ scope: "working-tree",
42
+ targetDescription: "current working tree changes",
43
+ };
44
+ }
45
+ export async function resolveRepoIdentity(cwd) {
46
+ const workspaceRoot = await realpath(cwd);
47
+ const result = await runGitAllowFailure(["rev-parse", "--show-toplevel", "--git-common-dir"], cwd);
48
+ if (!result.ok) {
49
+ return {
50
+ repoId: sha256(workspaceRoot),
51
+ repoRoot: workspaceRoot,
52
+ gitCommonDir: null,
53
+ isGitRepo: false,
54
+ };
55
+ }
56
+ const [repoRootRaw, gitCommonDirRaw] = result.stdout
57
+ .trim()
58
+ .split("\n")
59
+ .map((line) => line.trim())
60
+ .filter(Boolean);
61
+ const repoRoot = repoRootRaw ? await realpath(repoRootRaw) : workspaceRoot;
62
+ const gitCommonDir = gitCommonDirRaw && gitCommonDirRaw.length > 0
63
+ ? await realpath(path.resolve(cwd, gitCommonDirRaw))
64
+ : null;
65
+ return {
66
+ repoId: sha256(`${gitCommonDir ?? repoRoot}::${repoRoot}`),
67
+ repoRoot,
68
+ gitCommonDir,
69
+ isGitRepo: true,
70
+ };
71
+ }
72
+ async function getBaseDiff(cwd, base) {
73
+ // --end-of-options is a git safety marker (since 2.24) telling git every token after it is
74
+ // a refspec, not a flag. Defense in depth against argument injection via --base.
75
+ const threeDot = await runGitAllowFailure(["diff", "--no-ext-diff", "--unified=3", "--end-of-options", `${base}...HEAD`, "--"], cwd);
76
+ if (threeDot.ok) {
77
+ return threeDot.stdout;
78
+ }
79
+ return runGit(["diff", "--no-ext-diff", "--unified=3", "--end-of-options", base, "HEAD", "--"], cwd);
80
+ }
81
+ async function runGit(args, cwd) {
82
+ const result = await runGitAllowFailure(args, cwd);
83
+ if (!result.ok) {
84
+ throw new RuntimeError("GIT_COMMAND_FAILED", `git ${args.join(" ")} failed: ${result.stderr || "unknown error"}`, "review.git");
85
+ }
86
+ return result.stdout;
87
+ }
88
+ async function runGitAllowFailure(args, cwd, signal) {
89
+ try {
90
+ const { stdout, stderr } = await execFileAsync("git", args, {
91
+ cwd,
92
+ env: process.env,
93
+ maxBuffer: 10 * 1024 * 1024,
94
+ signal,
95
+ });
96
+ return { ok: true, stdout, stderr };
97
+ }
98
+ catch (error) {
99
+ const execError = error;
100
+ return {
101
+ ok: false,
102
+ stdout: execError.stdout ?? "",
103
+ stderr: execError.stderr ?? execError.message,
104
+ };
105
+ }
106
+ }
107
+ function truncateDiff(diffText) {
108
+ if (diffText.length <= MAX_DIFF_CHARS) {
109
+ return diffText;
110
+ }
111
+ return `${diffText.slice(0, MAX_DIFF_CHARS)}\n\n[diff truncated by companion after ${MAX_DIFF_CHARS} characters]`;
112
+ }
113
+ function sha256(value) {
114
+ return createHash("sha256").update(value).digest("hex");
115
+ }
116
+ // ---------------------------------------------------------------------
117
+ // Ephemeral worktree lifecycle — write-swarm.
118
+ //
119
+ // A write-swarm spawns its coordinator `kimi -p` with cwd = a throwaway
120
+ // detached worktree off the user's HEAD, so `coder` subagents edit there and
121
+ // never touch the user's checkout. The worktree is a LINKED worktree: its
122
+ // working tree + index are isolated and disposable, but it SHARES the main
123
+ // repo's object DB and refs. The PreToolUse hook (swarm-write label) confines
124
+ // every subagent write to this worktree; the plugin captures the result as a
125
+ // patch and removes the worktree. See docs/safety.md.
126
+ // ---------------------------------------------------------------------
127
+ /**
128
+ * True if the repo at `cwd` has a born HEAD (at least one commit). `git worktree
129
+ * add --detach HEAD` fails on an unborn HEAD (fresh repo, no commits) and on a
130
+ * bare repo, so write-swarm gates on this before creating any job state.
131
+ */
132
+ export async function hasBornHead(cwd) {
133
+ const result = await runGitAllowFailure(["rev-parse", "--verify", "--quiet", "HEAD"], cwd);
134
+ return result.ok && result.stdout.trim().length > 0;
135
+ }
136
+ /**
137
+ * Create a throwaway detached worktree at `worktreePath` checked out to `base`
138
+ * (write-swarm hard-codes "HEAD"; never user input). `worktreePath` MUST be
139
+ * outside the repo (the plugin's worktrees dir). Throws GIT_WORKTREE_ADD_FAILED
140
+ * on failure. Caller owns removal (`removeWorktree`) on EVERY terminal path.
141
+ */
142
+ export async function createEphemeralWorktree(repoRoot, base, worktreePath) {
143
+ const result = await runGitAllowFailure(["worktree", "add", "--detach", worktreePath, base], repoRoot);
144
+ if (!result.ok) {
145
+ throw new RuntimeError("GIT_WORKTREE_ADD_FAILED", `git worktree add failed: ${result.stderr || "unknown error"}`, "swarm.worktree", { details: { repoRoot, worktreePath, base } });
146
+ }
147
+ }
148
+ /**
149
+ * Capture the full change set in a worktree as a byte-complete, applyable patch.
150
+ *
151
+ * `git add -N -- .` records intent-to-add index entries for untracked files so
152
+ * the diff includes their content — WITHOUT writing blob objects into the shared
153
+ * object DB (intent-to-add stores a zero-oid entry in the worktree's OWN index
154
+ * only). This keeps the "plugin never mutates git state" invariant literally
155
+ * true: no loose objects, no ref/HEAD movement.
156
+ *
157
+ * The diff is taken against **HEAD** (the worktree's base commit), NOT the index:
158
+ * `git diff HEAD` captures the complete change set regardless of index state, so
159
+ * the patch is invariant to any intermediate staging (e.g. if a tool ever
160
+ * `git add`s a file, a plain `git diff` would silently omit it; `git diff HEAD`
161
+ * still captures it). `--binary` makes binary changes applyable; the diff is
162
+ * NEVER truncated (a capture patch must apply cleanly) and uses a raised
163
+ * maxBuffer. Mirrors `collectReviewContext`'s HEAD-based diff.
164
+ *
165
+ * Returns the patch text (empty string if the swarm changed nothing).
166
+ */
167
+ export async function captureWorktreePatch(worktreePath, signal) {
168
+ // Intent-to-add is required so untracked files surface in `git diff HEAD`.
169
+ // An add -N failure aborts capture rather than returning a patch that omits
170
+ // new files.
171
+ const intentToAdd = await runGitAllowFailure(["add", "-N", "--", "."], worktreePath, signal);
172
+ if (!intentToAdd.ok) {
173
+ throw new RuntimeError("GIT_WORKTREE_INTENT_TO_ADD_FAILED", `git add -N (worktree capture) failed: ${intentToAdd.stderr || "unknown error"}`, "swarm.worktree", { details: { worktreePath } });
174
+ }
175
+ try {
176
+ const { stdout } = await execFileAsync("git", ["diff", "--binary", "HEAD", "--"], {
177
+ cwd: worktreePath,
178
+ env: process.env,
179
+ maxBuffer: MAX_PATCH_BYTES,
180
+ signal,
181
+ });
182
+ return stdout;
183
+ }
184
+ catch (error) {
185
+ const execError = error;
186
+ throw new RuntimeError("GIT_WORKTREE_DIFF_FAILED", `git diff (worktree capture) failed: ${execError.stderr || execError.message || "unknown error"}`, "swarm.worktree", { details: { worktreePath } });
187
+ }
188
+ }
189
+ /**
190
+ * Remove a worktree (best-effort, idempotent). `--force` discards the dirty tree,
191
+ * so callers MUST capture the patch BEFORE calling this. Falls back to pruning the
192
+ * admin entry and removing the on-disk dir if `worktree remove` cannot (e.g. the
193
+ * dir was partially deleted). Never throws — cleanup must not mask the real error
194
+ * on a failure path.
195
+ */
196
+ export async function removeWorktree(repoRoot, worktreePath) {
197
+ const removed = await runGitAllowFailure(["worktree", "remove", "--force", worktreePath], repoRoot);
198
+ if (!removed.ok) {
199
+ // Prune the admin entry (handles the case where the dir is already gone) then
200
+ // force-delete any on-disk remnant so no orphan accretes in the plugin dir.
201
+ await runGitAllowFailure(["worktree", "prune"], repoRoot);
202
+ await rm(worktreePath, { recursive: true, force: true }).catch(() => {
203
+ // Best-effort; a residual dir is swept on the next run.
204
+ });
205
+ }
206
+ }
207
+ /**
208
+ * Prune stale worktree admin entries (entries whose on-disk dir is gone). Cheap;
209
+ * run at the start of every write-swarm. Does NOT remove on-disk worktrees whose
210
+ * dirs still exist — the caller sweeps those by path. Never throws.
211
+ */
212
+ export async function pruneWorktrees(repoRoot) {
213
+ await runGitAllowFailure(["worktree", "prune"], repoRoot);
214
+ }
215
+ /**
216
+ * True if the working tree at `cwd` has uncommitted changes (tracked or
217
+ * untracked). write-swarm bases its worktree on HEAD, so a dirty tree means the
218
+ * user's in-progress work is NOT visible to the swarm — the caller warns. Returns
219
+ * false on a git failure (don't block the run on a status probe).
220
+ */
221
+ export async function isWorkingTreeDirty(cwd) {
222
+ const result = await runGitAllowFailure(["status", "--porcelain", "--untracked-files=all"], cwd);
223
+ return result.ok && result.stdout.trim().length > 0;
224
+ }
@@ -0,0 +1,43 @@
1
+ import { hasHelpFlag, ASK_SUPPORTED_FLAGS, RESCUE_SUPPORTED_FLAGS, REVIEW_SUPPORTED_FLAGS, PURSUE_SUPPORTED_FLAGS, SWARM_SUPPORTED_FLAGS, JOB_LOOKUP_SUPPORTED_FLAGS, SETUP_SUPPORTED_FLAGS, REPAIR_SESSIONS_SUPPORTED_FLAGS, REPLAY_SUPPORTED_FLAGS, RECOVER_SWARM_SUPPORTED_FLAGS, } from "./parsing.js";
2
+ const SUBCOMMANDS = "setup, review, task, ask, status, result, cancel, replay, repair-sessions, recover-swarm";
3
+ const TASK_KINDS = "rescue, challenge, pursue, swarm";
4
+ /** Help is a dispatch-only, side-effect-free exception to normal argument parsing. */
5
+ export function companionHelp(argv) {
6
+ const [command, kind] = argv;
7
+ // Scan only the argument region: the subcommand (and a task kind) are
8
+ // positional names, not prose, so they must not end the flag region.
9
+ const argumentStart = command === "--help" || command === "-h" ? 0
10
+ : command === "task" && kind !== undefined && !kind.startsWith("-") ? 2
11
+ : 1;
12
+ if (!hasHelpFlag(argv.slice(argumentStart)))
13
+ return undefined;
14
+ const suffix = " Thinking effort is a per-run request, not a guarantee. Use --help or -h among the leading flags (before any prompt text or --) to show this usage.";
15
+ const usage = (name, positionals, flags, description) => `Usage: companion.sh ${name}${positionals ? ` ${positionals}` : ""}. ${description} Supported flags: ${flags}.${suffix}`;
16
+ switch (command) {
17
+ case "--help":
18
+ case "-h":
19
+ return `Usage: companion.sh <subcommand> [arguments]. Subcommands: ${SUBCOMMANDS}. Use <subcommand> --help for usage.`;
20
+ case "task":
21
+ switch (kind) {
22
+ case "rescue": return usage("task rescue", "[options] [--] <task>", RESCUE_SUPPORTED_FLAGS, "Write-capable task; default budget 45m. Backgrounding does not extend the budget.");
23
+ case "challenge": return usage("task challenge", "[options] [--] [focus]", REVIEW_SUPPORTED_FLAGS, "Read-only challenge; default budget 30m. Foreground-only (no --background/--wait).");
24
+ case "pursue": return usage("task pursue", "[options] [--] <objective>", PURSUE_SUPPORTED_FLAGS, "Autonomous goal; default budget 45m. Runtime foreground-only.");
25
+ case "swarm": return usage("task swarm", "[options] [--] <objective>", SWARM_SUPPORTED_FLAGS, "Parallel task; default budget 30m. Runtime foreground-only; --write produces a patch.");
26
+ default: return `Usage: companion.sh task <kind> [arguments]. Task kinds: ${TASK_KINDS}. Use task <kind> --help for usage.`;
27
+ }
28
+ case "ask": return usage(command, "[options] [--] [prompt]", ASK_SUPPORTED_FLAGS, "Read-only question; default budget 30m. Backgrounding does not extend the budget.");
29
+ case "review": return usage(command, "[options] [--] [focus]", REVIEW_SUPPORTED_FLAGS, "Read-only review; default budget 30m. Foreground-only (no --background/--wait).");
30
+ case "setup": return usage(command, "[options]", SETUP_SUPPORTED_FLAGS, "Install, check, or remove this host's managed hook, or list configured models.");
31
+ case "status":
32
+ case "result":
33
+ case "cancel":
34
+ return usage(command, "[job-id] [options]", JOB_LOOKUP_SUPPORTED_FLAGS, command === "status" ? "Show job state as JSON." : command === "result" ? "Read a terminal artifact, optionally in a JSON envelope." : "Cancel a running job; without an id, target the repository's latest running job.");
35
+ case "replay": return usage(command, "<job-id>", REPLAY_SUPPORTED_FLAGS, "Replay a completed job's persisted stream log.");
36
+ case "recover-swarm": return `Usage: companion.sh recover-swarm <job-id> [${RECOVER_SWARM_SUPPORTED_FLAGS}]. Preview durable swarm recovery; --apply stops verified orphans, disables the recorded session and captures a patch. Never resumes prompts or changes terminal job outcomes.`;
37
+ case "repair-sessions": return usage(command, "[options]", REPAIR_SESSIONS_SUPPORTED_FLAGS, "Preview session metadata repair; --apply writes, --all selects all host-owned repositories.");
38
+ case "worker": return "Usage: companion.sh worker <ask|rescue> <job-id>. Internal detached-worker entry point; launched by ask/rescue, not manually.";
39
+ case "mcp": return "Usage: companion.sh mcp. Run the Kimi MCP stdio server; normally launched by Claude Code or Codex plugin configuration.";
40
+ case "mcp-child": return "Usage: companion.sh mcp-child <job-uuid> <model-operation> [arguments]. Internal supervised MCP child; launched by the MCP server, not manually.";
41
+ default: return undefined;
42
+ }
43
+ }
@@ -0,0 +1,71 @@
1
+ import { decideHookOutcomePure } from "./hooks/approval-policy.js";
2
+ // Upstream decorates results it still delivers verbatim otherwise (2.2.0,
3
+ // `agent/toolDedupe/toolDedupeService.ts`): the repeat breaker appends one
4
+ // `\n\n<system-reminder>…</system-reminder>` block from the 3rd identical call
5
+ // (`appendReminder`, also for vetoed results — `finalizeToolResult` runs the
6
+ // `onDidExecuteTool` hooks on every settled result), and at the 12th call it
7
+ // replaces the result with a fixed "not executed" text (`HANDOFF_VETO_TEXT`).
8
+ // Both still mean the hook's veto stood. Re-verify each certification.
9
+ const REMINDER_SUFFIX = /^(?:\n\n<system-reminder>[\s\S]*?<\/system-reminder>)+$/;
10
+ const HANDOFF_VETO_PREFIX = "This turn was ended by the repeat breaker after the same tool call was issued ";
11
+ /** True when `content` is the hook's denial, possibly carrying upstream's repeat-breaker decoration. */
12
+ export function carriesDenial(content, reason) {
13
+ if (content === reason)
14
+ return true;
15
+ if (content.startsWith(reason) && REMINDER_SUFFIX.test(content.slice(reason.length)))
16
+ return true;
17
+ return content.startsWith(HANDOFF_VETO_PREFIX);
18
+ }
19
+ /** Detection evidence, never enforcement. Results arrive after a tool may have
20
+ * executed, and the stream carries no outcome field: a result other than the
21
+ * managed denial is also what upstream emits for calls it rejected BEFORE the
22
+ * hook ran (unknown tool, disabled tool, invalid args, sensitive path, per-tool
23
+ * resolve errors) and what a coexisting hook or permission policy emits when it
24
+ * blocks first. So an escape is reported — logged, persisted, shown — and the
25
+ * run continues; failing the job needs positive execution evidence this stream
26
+ * does not provide. Filesystem policy is never replayed here. */
27
+ export function createHookEscapeMonitor(ctx) {
28
+ const pending = new Map();
29
+ return {
30
+ observe(record) {
31
+ if (!ctx.commandLabel)
32
+ return;
33
+ if (record.role === "assistant") {
34
+ for (const call of record.tool_calls ?? []) {
35
+ let args;
36
+ try {
37
+ args = JSON.parse(call.function.arguments);
38
+ }
39
+ catch {
40
+ args = {};
41
+ }
42
+ if (typeof args !== "object" || args === null || Array.isArray(args))
43
+ args = {};
44
+ const result = decideHookOutcomePure({
45
+ hook_event_name: "PreToolUse", tool_name: call.function.name,
46
+ tool_input: args, tool_call_id: call.id, cwd: ctx.cwd,
47
+ }, ctx);
48
+ if (!("evaluator" in result) && result.decision === "deny") {
49
+ pending.set(call.id, { reason: result.reason, tool_name: call.function.name });
50
+ }
51
+ }
52
+ }
53
+ else if (record.role === "tool") {
54
+ const denied = pending.get(record.tool_call_id);
55
+ if (denied) {
56
+ pending.delete(record.tool_call_id);
57
+ if (!carriesDenial(record.content, denied.reason)) {
58
+ return { tool_call_id: record.tool_call_id, tool_name: denied.tool_name,
59
+ kind: "mismatch", content_excerpt: record.content.slice(0, 200) };
60
+ }
61
+ }
62
+ }
63
+ },
64
+ /** Every denied call still without a result; call once at settlement. */
65
+ settle() {
66
+ const unresolved = [...pending.entries()].map(([tool_call_id, { tool_name }]) => ({ tool_call_id, tool_name, kind: "unresolved" }));
67
+ pending.clear();
68
+ return unresolved;
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // Entry script for the kimi-code PreToolUse hook.
3
+ //
4
+ // Installed by /kimi:setup as a managed block in ~/.kimi-code/config.toml:
5
+ //
6
+ // [[hooks]]
7
+ // event = "PreToolUse"
8
+ // command = "'/abs/path/to/node' '/abs/path/to/dist/hooks/approval-hook.js'"
9
+ // timeout = 15
10
+ //
11
+ // Hook protocol:
12
+ //
13
+ // stdin: full hook payload as JSON
14
+ // stdout: optional structured JSON (only consulted when exit code is 0)
15
+ // stderr: free-form reason
16
+ // exit 0: allow (with optional structured override)
17
+ // exit 2: deny — `stderr.trim()` becomes the reason surfaced to the model
18
+ // any other exit code: fall through to allow (fail-open)
19
+ //
20
+ // Exit 2 + stderr avoids structured-output version-skew hazards, and the
21
+ // runner short-circuits it before reading structured output. Because any other
22
+ // exit fails open upstream, every code path here is wrapped so an unexpected
23
+ // error exits 2 with a "hook misconfigured" reason instead.
24
+ //
25
+ // kimi-code spawns the command with `shell: true`. The canonical command is two
26
+ // shell-quoted absolute tokens — the resolved Node binary and hook script — so
27
+ // `/bin/sh -c` does not depend on its working directory or PATH.
28
+ import { decideHookOutcome } from "./approval-policy.js";
29
+ import { evaluateRescueHookRequest } from "../rescue-approval.js";
30
+ const STDIN_TIMEOUT_MS = 5_000;
31
+ async function main() {
32
+ const stdinText = await readStdinWithTimeout(STDIN_TIMEOUT_MS);
33
+ let input;
34
+ try {
35
+ const parsed = JSON.parse(stdinText);
36
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
37
+ throw new Error("hook payload must be a JSON object");
38
+ }
39
+ input = parsed;
40
+ }
41
+ catch (err) {
42
+ failClosed(`stdin was not a JSON object: ${formatErr(err)}`);
43
+ return;
44
+ }
45
+ const decision = await decideHookOutcome(input, {
46
+ commandLabel: process.env.KIMI_PLUGIN_CC_CMD,
47
+ operationKind: process.env.KIMI_PLUGIN_CC_OPERATION,
48
+ rescueEvaluator: evaluateRescueHookRequest,
49
+ // Trusted root for rescue/pursue/swarm-write. Set by the plugin spawn; the
50
+ // model inside kimi cannot forge it and hook payload cwd is not trusted.
51
+ trustedWorkspaceRoot: process.env.KIMI_PLUGIN_CC_WORKSPACE_ROOT,
52
+ });
53
+ if (decision.decision === "deny") {
54
+ // stderr.trim() in runner.ts:118 becomes the model-visible reason.
55
+ process.stderr.write(`${decision.reason ?? "denied"}\n`);
56
+ process.exit(2);
57
+ return;
58
+ }
59
+ process.exit(0);
60
+ }
61
+ function failClosed(reason) {
62
+ process.stderr.write(`kimi-plugin-cc safety hook misconfigured: ${reason}\n`);
63
+ process.exit(2);
64
+ }
65
+ function formatErr(err) {
66
+ return err instanceof Error ? err.message : String(err);
67
+ }
68
+ function readStdinWithTimeout(timeoutMs) {
69
+ return new Promise((resolve, reject) => {
70
+ let buf = "";
71
+ let settled = false;
72
+ const finish = (value) => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ if (value instanceof Error)
78
+ reject(value);
79
+ else
80
+ resolve(value);
81
+ };
82
+ const timer = setTimeout(() => {
83
+ finish(new Error(`stdin did not close within ${timeoutMs}ms`));
84
+ }, timeoutMs);
85
+ timer.unref();
86
+ try {
87
+ process.stdin.setEncoding("utf8");
88
+ }
89
+ catch {
90
+ // some test harnesses pre-encode; fine to ignore
91
+ }
92
+ process.stdin.on("data", (chunk) => {
93
+ buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
94
+ });
95
+ process.stdin.on("end", () => finish(buf));
96
+ process.stdin.on("error", (err) => finish(err instanceof Error ? err : new Error(String(err))));
97
+ });
98
+ }
99
+ main().catch((err) => {
100
+ failClosed(`uncaught error: ${formatErr(err)}`);
101
+ });
@@ -0,0 +1,211 @@
1
+ // Pure decision logic for the kimi-code PreToolUse hook. The entry script owns
2
+ // stdin/stdout/exit semantics so callers can test this policy without mocking
3
+ // process IO.
4
+ //
5
+ // `kimi -p` hard-codes `permission: 'auto'`, and PreToolUse fires before
6
+ // permission rules, so this hook is load-bearing for read-only commands.
7
+ // Unknown command labels fail closed to READ_ONLY_TOOLS. An absent
8
+ // KIMI_PLUGIN_CC_CMD means out-of-plugin use and remains unrestricted.
9
+ /**
10
+ * Tools considered read-only across review/challenge/review_gate/rescue.
11
+ *
12
+ * Includes kimi-code's own local-read defaults (`Read`, `Grep`, `Glob`)
13
+ * plus a few additional read-safe tools the runtime auto-allows
14
+ * (`ReadMediaFile`, `TaskList`, `TaskOutput`). Excludes network
15
+ * operations (`WebSearch`, `FetchURL`) since review-style commands
16
+ * should stay on the local repo — adding them would silently broaden
17
+ * the safety contract for what is documented as a local-only review.
18
+ */
19
+ export const READ_ONLY_TOOLS = new Set([
20
+ "Read",
21
+ "Grep",
22
+ "Glob",
23
+ "ReadMediaFile",
24
+ "TaskList",
25
+ "TaskOutput",
26
+ ]);
27
+ /**
28
+ * Exact tool name for kimi-code's parallel fan-out. Every spawned subagent
29
+ * inherits KIMI_PLUGIN_CC_CMD. On native v2, each agent has its own eager
30
+ * external-hooks service, so this same hook denies subagent writes. Legacy v1
31
+ * separately relies on PreToolCallHookPermissionPolicy at policy index 0 for
32
+ * every agent. The singular `Agent` tool remains denied: only the AgentSwarm
33
+ * fan-out surface is allowlisted.
34
+ */
35
+ const AGENT_SWARM_TOOL = "AgentSwarm";
36
+ /**
37
+ * agent-core-v2's ONLY chain-breaking final allow is the plan-file guard, and
38
+ * it fires only while plan mode is ACTIVE. Native v2 is therefore certified on
39
+ * the invariant that plan mode is never armed in a plugin-managed session:
40
+ * `default_plan_mode` is refused pre-spawn (runtime/native-v2-preflight.ts),
41
+ * plan-tainted sessions are never resumed, and the model-reachable entry tool
42
+ * is denied HERE for every label. ExitPlanMode is denied too so the deny set
43
+ * is explicit rather than a side effect of the read-only allowlists.
44
+ */
45
+ const PLAN_MODE_TOOLS = new Set(["EnterPlanMode", "ExitPlanMode"]);
46
+ /**
47
+ * Recognized command labels. Sole source of truth; keep in sync with
48
+ * the strings emitted by `runCliPrompt` callers in
49
+ * `runtime/commands/*.ts`.
50
+ */
51
+ const KNOWN_LABELS = new Set([
52
+ "ask",
53
+ "review",
54
+ "challenge",
55
+ "review_gate",
56
+ "rescue",
57
+ "swarm",
58
+ "swarm-write",
59
+ ]);
60
+ export async function decideHookOutcome(input, ctx) {
61
+ const result = decideHookOutcomePure(input, ctx);
62
+ if (!("evaluator" in result))
63
+ return result;
64
+ const toolName = typeof input.tool_name === "string" ? input.tool_name : "";
65
+ if (ctx.rescueEvaluator !== undefined) {
66
+ return await ctx.rescueEvaluator(result.workspaceRoot, toolName, input.tool_input);
67
+ }
68
+ if (READ_ONLY_TOOLS.has(toolName))
69
+ return { decision: "allow" };
70
+ return {
71
+ decision: "deny",
72
+ reason: `kimi-plugin-cc safety hook: ${ctx.commandLabel} evaluator not configured; tool "${toolName}" denied as a safety default.`,
73
+ };
74
+ }
75
+ export function decideHookOutcomePure(input, ctx) {
76
+ const label = typeof ctx.commandLabel === "string" ? ctx.commandLabel : undefined;
77
+ // Out-of-plugin context: any kimi process spawned without our env var
78
+ // is direct user usage. The hook MUST NOT restrict it.
79
+ if (label === undefined || label.length === 0) {
80
+ return { decision: "allow" };
81
+ }
82
+ const toolName = typeof input.tool_name === "string" ? input.tool_name : "";
83
+ if (PLAN_MODE_TOOLS.has(toolName)) {
84
+ return {
85
+ decision: "deny",
86
+ reason: `kimi-plugin-cc safety hook: tool "${toolName}" is denied for plugin-managed sessions because agent-core-v2 plan-file writes can bypass later external hooks; native plan mode is never armed in plugin-managed sessions.`,
87
+ };
88
+ }
89
+ if (["GetGoal", "UpdateGoal", "CreateGoal", "SetGoalBudget"].includes(toolName)) {
90
+ // Pursue keeps the rescue write boundary, but must be able to inspect and
91
+ // terminally settle its existing goal. Never allow creating/reactivating a
92
+ // goal or changing its budget through this metadata-only exception.
93
+ if (toolName === "SetGoalBudget" && label === "rescue" && ctx.operationKind === "pursue") {
94
+ return {
95
+ decision: "deny",
96
+ reason: `kimi-plugin-cc safety hook: tool "SetGoalBudget" is denied. The outer plugin wall-clock budget already applies; continue the current goal without retrying SetGoalBudget or changing its budget.`,
97
+ };
98
+ }
99
+ const args = input.tool_input;
100
+ const objectArgs = typeof args === "object" && args !== null && !Array.isArray(args) &&
101
+ (Object.getPrototypeOf(args) === Object.prototype || Object.getPrototypeOf(args) === null);
102
+ const keys = objectArgs ? Reflect.ownKeys(args) : [];
103
+ const validArgs = objectArgs && ((toolName === "GetGoal" && keys.length === 0) ||
104
+ (toolName === "UpdateGoal" && keys.length === 1 && keys[0] === "status" &&
105
+ (args.status === "complete" || args.status === "blocked")));
106
+ if (label === "rescue" && ctx.operationKind === "pursue" &&
107
+ typeof ctx.trustedWorkspaceRoot === "string" && ctx.trustedWorkspaceRoot.trim().length > 0 && validArgs) {
108
+ return { decision: "allow" };
109
+ }
110
+ return {
111
+ decision: "deny",
112
+ reason: `kimi-plugin-cc safety hook: tool "${toolName}" requires a pursue run with a trusted workspace root and exact terminal goal metadata arguments; goal creation, reactivation, and budget changes are denied.`,
113
+ };
114
+ }
115
+ switch (label) {
116
+ case "ask":
117
+ case "review":
118
+ case "challenge":
119
+ case "review_gate":
120
+ // Read-only narrative surfaces share READ_ONLY_TOOLS. They run as
121
+ // non-interactive subprocesses, so interactive tool prompts cannot
122
+ // substitute for this allowlist.
123
+ if (READ_ONLY_TOOLS.has(toolName)) {
124
+ return { decision: "allow" };
125
+ }
126
+ return {
127
+ decision: "deny",
128
+ reason: denyReadOnlyMessage(label, toolName),
129
+ };
130
+ case "swarm":
131
+ // The coordinator may launch AgentSwarm; every subagent inherits this
132
+ // label and is evaluated by the same hook, so only READ_ONLY_TOOLS plus
133
+ // AgentSwarm are allowed and the singular Agent stays denied. This adds
134
+ // no direct tool-write permission within the repository/Git trust
135
+ // boundary: read-only Git context commands still trust repository Git
136
+ // configuration and can execute configured helpers. Nested AgentSwarm
137
+ // calls remain allowed, with their cost bounded by the command budget.
138
+ if (READ_ONLY_TOOLS.has(toolName) || toolName === AGENT_SWARM_TOOL) {
139
+ return { decision: "allow" };
140
+ }
141
+ return {
142
+ decision: "deny",
143
+ reason: denySwarmMessage(toolName),
144
+ };
145
+ case "swarm-write": {
146
+ // Write-capable fan-out runs in an ephemeral worktree. AgentSwarm and the
147
+ // read-only set are allowed; the singular Agent is denied. Every other
148
+ // tool goes through the rescue evaluator scoped to the plugin-exported
149
+ // KIMI_PLUGIN_CC_WORKSPACE_ROOT, never the payload cwd, so an in-process
150
+ // subagent cannot move the trust boundary or forge its root. A missing
151
+ // root fails closed for writes.
152
+ if (READ_ONLY_TOOLS.has(toolName) || toolName === AGENT_SWARM_TOOL) {
153
+ return { decision: "allow" };
154
+ }
155
+ const workspaceRoot = typeof ctx.trustedWorkspaceRoot === "string" ? ctx.trustedWorkspaceRoot : "";
156
+ if (workspaceRoot.length === 0) {
157
+ return {
158
+ decision: "deny",
159
+ reason: `kimi-plugin-cc safety hook: swarm-write received no trusted workspace root; tool "${toolName}" denied. ` +
160
+ "This is a plugin misconfiguration — writes are refused rather than risk the user's working tree.",
161
+ };
162
+ }
163
+ return { evaluator: "rescue", workspaceRoot };
164
+ }
165
+ case "rescue": {
166
+ const workspaceRoot = typeof ctx.trustedWorkspaceRoot === "string" ? ctx.trustedWorkspaceRoot : "";
167
+ if (workspaceRoot.length === 0) {
168
+ if (READ_ONLY_TOOLS.has(toolName))
169
+ return { decision: "allow" };
170
+ return {
171
+ decision: "deny",
172
+ reason: `kimi-plugin-cc safety hook: rescue received no trusted workspace root; tool "${toolName}" denied. ` +
173
+ "This is a plugin misconfiguration — writes are refused rather than trusting hook payload cwd.",
174
+ };
175
+ }
176
+ return { evaluator: "rescue", workspaceRoot };
177
+ }
178
+ default:
179
+ if (KNOWN_LABELS.has(label)) {
180
+ return {
181
+ decision: "deny",
182
+ reason: `kimi-plugin-cc safety hook: command label "${label}" has no policy branch.`,
183
+ };
184
+ }
185
+ // Unknown label: fail closed to READ_ONLY_TOOLS only.
186
+ if (READ_ONLY_TOOLS.has(toolName)) {
187
+ return { decision: "allow" };
188
+ }
189
+ return {
190
+ decision: "deny",
191
+ reason: `kimi-plugin-cc safety hook: unrecognized command label "${label}"; tool "${toolName}" denied as a safety default.`,
192
+ };
193
+ }
194
+ }
195
+ function denyReadOnlyMessage(label, toolName) {
196
+ const tool = toolName.length > 0 ? toolName : "<unspecified>";
197
+ return [
198
+ `kimi-plugin-cc safety hook: ${label} is read-only.`,
199
+ `Tool "${tool}" is denied — use Read, Grep, or Glob to inspect the workspace instead.`,
200
+ "If you need to mutate state, the user must invoke /kimi:rescue (write-capable) rather than this command.",
201
+ ].join(" ");
202
+ }
203
+ function denySwarmMessage(toolName) {
204
+ const tool = toolName.length > 0 ? toolName : "<unspecified>";
205
+ return [
206
+ "kimi-plugin-cc safety hook: swarm is a read-only parallel review.",
207
+ `Tool "${tool}" is denied — every subagent may use Read, Grep, or Glob to inspect the workspace,`,
208
+ "and the coordinator may use AgentSwarm to fan out, but no write, edit, or shell operations are permitted.",
209
+ "Consolidate the subagents' findings into a markdown report instead.",
210
+ ].join(" ");
211
+ }