@pragma-sh/kimi-plugin 0.1.0-alpha.0
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/assets/kimi.png +0 -0
- package/dist/install.mjs +86 -0
- package/dist/pragma-plugin.mjs +437 -0
- package/hooks/report.sh +620 -0
- package/kimi.plugin.json +68 -0
- package/package.json +42 -0
- package/pragma-plugin.json +14 -0
- package/scripts/install-local.ts +111 -0
- package/src/models.ts +94 -0
- package/src/pragma-plugin.ts +103 -0
package/hooks/report.sh
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Pragma <-> Kimi Code status bridge.
|
|
3
|
+
#
|
|
4
|
+
# Invoked by kimi.plugin.json on Kimi lifecycle events (see the hook -> status
|
|
5
|
+
# table in AGENTS.md). Each event is translated into a `pragma-cli` status
|
|
6
|
+
# report for the current Pragma terminal tab. Outside a Pragma terminal
|
|
7
|
+
# PRAGMA_SERVER_SOCKET / PRAGMA_DAEMON_SOCKET are unset and there is no server
|
|
8
|
+
# to talk to, so every event is a silent no-op (exit 0).
|
|
9
|
+
#
|
|
10
|
+
# Unlike Claude Code and Grok, Kimi has a first-class cancel hook: the TUI's
|
|
11
|
+
# Esc/Ctrl+C cancels the running turn through the RPC `session.cancel()` path,
|
|
12
|
+
# which fires `Interrupt`, and `Stop` fires only for genuine completions
|
|
13
|
+
# (`StopFailure` covers API/step errors). Every terminal state has a hook, so
|
|
14
|
+
# this bridge is purely event-driven -- there is no transcript-abort watcher.
|
|
15
|
+
#
|
|
16
|
+
# Kimi's stdin envelope is snake_case throughout (`hook_event_name`,
|
|
17
|
+
# `session_id`, `tool_name`, ...), the same convention as Claude Code. The one
|
|
18
|
+
# kimi-specific quirk: `UserPromptSubmit` carries `prompt` as a ContentPart
|
|
19
|
+
# array (not a string), so its text parts must be joined before use.
|
|
20
|
+
#
|
|
21
|
+
# Subagents run their own agent scope but share the parent session id and add
|
|
22
|
+
# no distinguishing field (parallel subagents even share the default profile
|
|
23
|
+
# name), so they are accounted for explicitly: the SubagentStart/SubagentStop
|
|
24
|
+
# hooks maintain an active-child count, and a Stop/StopFailure arriving while
|
|
25
|
+
# children are tracked is a subagent turn ending -- never a parent completion --
|
|
26
|
+
# so the tab stays on `started` until the parent's own Stop.
|
|
27
|
+
|
|
28
|
+
set -u
|
|
29
|
+
|
|
30
|
+
# Outside Pragma there is no server to report to; every event is a silent no-op.
|
|
31
|
+
[ -n "${PRAGMA_SERVER_SOCKET:-}${PRAGMA_DAEMON_SOCKET:-}" ] || exit 0
|
|
32
|
+
|
|
33
|
+
agent="kimi"
|
|
34
|
+
pragma_cli="${PRAGMA_CLI:-pragma-cli}"
|
|
35
|
+
tab="${PRAGMA_TAB_ID:-unknown}"
|
|
36
|
+
state_dir="${TMPDIR:-/tmp}"
|
|
37
|
+
# Per-tab files: the marker holds the active turn's token (presence = a turn is
|
|
38
|
+
# in flight); the pidfile-free design needs no watcher state beyond this.
|
|
39
|
+
marker="${state_dir}/pragma-cli-${agent}-${tab}.active"
|
|
40
|
+
session_file="${state_dir}/pragma-cli-${agent}-${tab}.session"
|
|
41
|
+
# The session id that owns the in-flight marker, so a late `cleared` from a
|
|
42
|
+
# superseded session cannot wipe a turn that already started in a new one.
|
|
43
|
+
turn_session_file="${state_dir}/pragma-cli-${agent}-${tab}.turn-session"
|
|
44
|
+
# Holds the session_id whose name was already reported, so each session (incl.
|
|
45
|
+
# a resume switch to another session) names the tab exactly once.
|
|
46
|
+
named_file="${state_dir}/pragma-cli-${agent}-${tab}.sessionname"
|
|
47
|
+
|
|
48
|
+
# Reports a status to Pragma, swallowing every failure so a hook never disrupts
|
|
49
|
+
# a Kimi session (e.g. when pragma-cli or the server is unavailable).
|
|
50
|
+
report() {
|
|
51
|
+
"$pragma_cli" agent report --agent "$agent" "$@" >/dev/null 2>&1 || true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Reports a coarse rich message. Hook payloads are intentionally not parsed
|
|
55
|
+
# beyond the prompt extraction below; this keeps hooks fail-open and portable.
|
|
56
|
+
# AgentMessage.ts is milliseconds since Unix epoch (see @pragma/constants).
|
|
57
|
+
# `date +%s` is seconds -- multiply so chat clients that stamp local input with
|
|
58
|
+
# Date.now() don't sort every agent bubble above the user's messages.
|
|
59
|
+
message_ts_ms() {
|
|
60
|
+
echo $(($(date +%s) * 1000))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
message() {
|
|
64
|
+
role="$1"
|
|
65
|
+
text="$2"
|
|
66
|
+
id="${agent}-${tab}-$(date +%s)-$$-$role"
|
|
67
|
+
ts="$(message_ts_ms)"
|
|
68
|
+
active="$(tracked_subagent_count)"
|
|
69
|
+
payload='{"id":"'"$id"'","role":"'"$role"'","text":"'"$text"'","subAgentsActive":'"$active"',"ts":'"$ts"'}'
|
|
70
|
+
"$pragma_cli" agent message --agent "$agent" --payload "$payload" >/dev/null 2>&1 || true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Content-bearing messages (the user's prompt) need real JSON parsing/escaping
|
|
74
|
+
# that POSIX sh cannot do safely. Kimi itself ships as an npm package, so any
|
|
75
|
+
# machine that can run `kimi` already has a working `node` on PATH -- unlike
|
|
76
|
+
# python3, this adds no dependency beyond what Kimi already requires. When
|
|
77
|
+
# node is missing these helpers do nothing and the bridge degrades to the
|
|
78
|
+
# coarse status-only messages above.
|
|
79
|
+
node_bin="$(command -v node 2>/dev/null || true)"
|
|
80
|
+
if [ -n "$node_bin" ] && ! "$node_bin" -e 'process.exit(0)' >/dev/null 2>&1; then
|
|
81
|
+
node_bin=""
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
# Prints a top-level string field from the JSON document passed as $2.
|
|
85
|
+
json_field() {
|
|
86
|
+
[ -n "$node_bin" ] || return 0
|
|
87
|
+
printf '%s' "$2" | JSON_FIELD_KEY="$1" "$node_bin" -e '
|
|
88
|
+
let data = "";
|
|
89
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
90
|
+
process.stdin.on("end", () => {
|
|
91
|
+
let value;
|
|
92
|
+
try {
|
|
93
|
+
value = (JSON.parse(data) || {})[process.env.JSON_FIELD_KEY];
|
|
94
|
+
} catch {
|
|
95
|
+
value = undefined;
|
|
96
|
+
}
|
|
97
|
+
if (typeof value === "string") process.stdout.write(value + "\n");
|
|
98
|
+
});
|
|
99
|
+
' 2>/dev/null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Prints a top-level JSON value of any type (not just strings) from the
|
|
103
|
+
# document passed as $2, verbatim. json_field above refuses non-string values,
|
|
104
|
+
# so this exists to hand the ContentPart array under `prompt` to prompt_text.
|
|
105
|
+
json_raw() {
|
|
106
|
+
[ -n "$node_bin" ] || return 0
|
|
107
|
+
printf '%s' "$2" | JSON_FIELD_KEY="$1" "$node_bin" -e '
|
|
108
|
+
let data = "";
|
|
109
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
110
|
+
process.stdin.on("end", () => {
|
|
111
|
+
let value;
|
|
112
|
+
try {
|
|
113
|
+
value = (JSON.parse(data) || {})[process.env.JSON_FIELD_KEY];
|
|
114
|
+
} catch {
|
|
115
|
+
value = undefined;
|
|
116
|
+
}
|
|
117
|
+
if (value !== undefined && value !== null) process.stdout.write(JSON.stringify(value) + "\n");
|
|
118
|
+
});
|
|
119
|
+
' 2>/dev/null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Kimi hands `UserPromptSubmit` a ContentPart array (`prompt`) rather than a
|
|
123
|
+
# plain string; joins the text parts so chat bubbles and derived session names
|
|
124
|
+
# show what the user actually typed.
|
|
125
|
+
prompt_text() {
|
|
126
|
+
[ -n "$node_bin" ] || return 0
|
|
127
|
+
printf '%s' "$1" | "$node_bin" -e '
|
|
128
|
+
let data = "";
|
|
129
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
130
|
+
process.stdin.on("end", () => {
|
|
131
|
+
let value;
|
|
132
|
+
try {
|
|
133
|
+
value = JSON.parse(data || "\"\"");
|
|
134
|
+
} catch {
|
|
135
|
+
value = "";
|
|
136
|
+
}
|
|
137
|
+
if (typeof value === "string") {
|
|
138
|
+
process.stdout.write(value + "\n");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!Array.isArray(value)) return;
|
|
142
|
+
const parts = [];
|
|
143
|
+
for (const part of value) {
|
|
144
|
+
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") {
|
|
145
|
+
parts.push(part.text);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
process.stdout.write(parts.join("") + "\n");
|
|
149
|
+
});
|
|
150
|
+
' 2>/dev/null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Prints a tab-title-sized session name derived from the prompt's first line.
|
|
154
|
+
# Silent no-op without node (the session simply stays unnamed).
|
|
155
|
+
session_name_from_prompt() {
|
|
156
|
+
[ -n "$node_bin" ] || return 0
|
|
157
|
+
printf '%s' "$1" | "$node_bin" -e '
|
|
158
|
+
let data = "";
|
|
159
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
160
|
+
process.stdin.on("end", () => {
|
|
161
|
+
const lines = data.trim().split(/\r?\n/);
|
|
162
|
+
const line = (lines[0] || "").trim();
|
|
163
|
+
const out = line.length <= 48 ? line : line.slice(0, 47).replace(/\s+$/, "") + "\u2026";
|
|
164
|
+
process.stdout.write(out + "\n");
|
|
165
|
+
});
|
|
166
|
+
' 2>/dev/null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Kimi exposes no conversation title to hooks, so the session is named after
|
|
170
|
+
# its first real user prompt; switching sessions renames on that session's
|
|
171
|
+
# first prompt. Pragma preserves manual tab renames regardless.
|
|
172
|
+
report_session_name() {
|
|
173
|
+
prompt_text_value="$1"
|
|
174
|
+
[ -n "$hook_session_id" ] || return 0
|
|
175
|
+
[ "$(cat "$named_file" 2>/dev/null)" = "$hook_session_id" ] && return 0
|
|
176
|
+
session_name="$(session_name_from_prompt "$prompt_text_value")"
|
|
177
|
+
[ -n "$session_name" ] || return 0
|
|
178
|
+
report session-name --name "$session_name"
|
|
179
|
+
printf '%s' "$hook_session_id" >"$named_file"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Reports a rich message whose text is JSON-escaped (safe for arbitrary
|
|
183
|
+
# content). Silent no-op without node or when the text is empty.
|
|
184
|
+
content_message() {
|
|
185
|
+
role="$1"
|
|
186
|
+
text="$2"
|
|
187
|
+
[ -n "$node_bin" ] && [ -n "$text" ] || return 0
|
|
188
|
+
id="${agent}-${tab}-$(date +%s)-$$-$role"
|
|
189
|
+
ts="$(message_ts_ms)"
|
|
190
|
+
active="$(tracked_subagent_count)"
|
|
191
|
+
payload=$(MSG_ID="$id" MSG_ROLE="$role" MSG_TEXT="$text" MSG_TS="$ts" MSG_ACTIVE="$active" "$node_bin" -e '
|
|
192
|
+
process.stdout.write(JSON.stringify({
|
|
193
|
+
id: process.env.MSG_ID,
|
|
194
|
+
role: process.env.MSG_ROLE,
|
|
195
|
+
text: process.env.MSG_TEXT,
|
|
196
|
+
subAgentsActive: Number(process.env.MSG_ACTIVE),
|
|
197
|
+
ts: Number(process.env.MSG_TS),
|
|
198
|
+
}));
|
|
199
|
+
' 2>/dev/null)
|
|
200
|
+
[ -n "$payload" ] || return 0
|
|
201
|
+
"$pragma_cli" agent message --agent "$agent" --payload "$payload" >/dev/null 2>&1 || true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Prints the newest assistant text part from the session's wire transcript —
|
|
205
|
+
# the reply the turn that just stopped produced. Kimi's `Stop` payload carries
|
|
206
|
+
# no reply text, so the transcript (written as the turn streams) is the only
|
|
207
|
+
# channel for the actual answer. Empty output when unavailable (no node, a
|
|
208
|
+
# changed session layout, or a turn that ended without text), letting callers
|
|
209
|
+
# fall back to a coarse completion message.
|
|
210
|
+
last_assistant_text() {
|
|
211
|
+
session_id="$1"
|
|
212
|
+
[ -n "$node_bin" ] && [ -n "$session_id" ] || return 0
|
|
213
|
+
transcript="$(printf '%s' "$HOME/.kimi-code/sessions"/*/"$session_id"/agents/main/wire.jsonl 2>/dev/null | head -n 1)"
|
|
214
|
+
[ -n "$transcript" ] && [ -f "$transcript" ] || return 0
|
|
215
|
+
MSG_TRANSCRIPT="$transcript" "$node_bin" -e '
|
|
216
|
+
let fs = require("fs");
|
|
217
|
+
let last = "";
|
|
218
|
+
for (const line of fs.readFileSync(process.env.MSG_TRANSCRIPT, "utf8").split("\n")) {
|
|
219
|
+
let value;
|
|
220
|
+
try {
|
|
221
|
+
value = JSON.parse(line);
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!value || value.type !== "context.append_loop_event") continue;
|
|
226
|
+
const event = value.event || {};
|
|
227
|
+
if (event.type !== "content.part") continue;
|
|
228
|
+
const part = event.part || {};
|
|
229
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
230
|
+
last = part.text;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (last) process.stdout.write(last);
|
|
234
|
+
' 2>/dev/null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Prints the question text from a PreToolUse `AskUserQuestion` payload ($1)
|
|
238
|
+
# when it carries exactly one question. Empty for multi-question payloads
|
|
239
|
+
# (those fall back to a generic attention + Kimi's native question UI) or
|
|
240
|
+
# without node.
|
|
241
|
+
question_text() {
|
|
242
|
+
[ -n "$node_bin" ] || return 0
|
|
243
|
+
printf '%s' "$1" | "$node_bin" -e '
|
|
244
|
+
let data = "";
|
|
245
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
246
|
+
process.stdin.on("end", () => {
|
|
247
|
+
let toolInput;
|
|
248
|
+
try {
|
|
249
|
+
toolInput = (JSON.parse(data) || {}).tool_input;
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (typeof toolInput === "string") {
|
|
254
|
+
try {
|
|
255
|
+
toolInput = JSON.parse(toolInput);
|
|
256
|
+
} catch {
|
|
257
|
+
toolInput = {};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!toolInput || typeof toolInput !== "object") return;
|
|
261
|
+
const questions = toolInput.questions;
|
|
262
|
+
if (Array.isArray(questions) && questions.length === 1 && questions[0] && typeof questions[0] === "object") {
|
|
263
|
+
const text = questions[0].question;
|
|
264
|
+
if (typeof text === "string" && text) process.stdout.write(text + "\n");
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
' 2>/dev/null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
# Prints the first question's options as pragma-cli QuestionOption JSON.
|
|
271
|
+
question_options() {
|
|
272
|
+
[ -n "$node_bin" ] || return 0
|
|
273
|
+
printf '%s' "$1" | "$node_bin" -e '
|
|
274
|
+
let data = "";
|
|
275
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
276
|
+
process.stdin.on("end", () => {
|
|
277
|
+
try {
|
|
278
|
+
const input = (JSON.parse(data) || {}).tool_input || {};
|
|
279
|
+
const question = Array.isArray(input.questions) ? input.questions[0] : null;
|
|
280
|
+
const options = Array.isArray(question?.options)
|
|
281
|
+
? question.options.flatMap((option) => typeof option?.label === "string" && option.label
|
|
282
|
+
? [{ label: option.label, ...(typeof option.description === "string" && option.description ? { description: option.description } : {}) }]
|
|
283
|
+
: [])
|
|
284
|
+
: [];
|
|
285
|
+
if (options.length) process.stdout.write(JSON.stringify(options));
|
|
286
|
+
} catch {}
|
|
287
|
+
});
|
|
288
|
+
' 2>/dev/null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Prints all questions for a multi-question attention, or nothing for one.
|
|
292
|
+
question_json() {
|
|
293
|
+
[ -n "$node_bin" ] || return 0
|
|
294
|
+
printf '%s' "$1" | "$node_bin" -e '
|
|
295
|
+
let data = "";
|
|
296
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
297
|
+
process.stdin.on("end", () => {
|
|
298
|
+
try {
|
|
299
|
+
const input = (JSON.parse(data) || {}).tool_input || {};
|
|
300
|
+
if (input.background === true || !Array.isArray(input.questions) || input.questions.length < 2) return;
|
|
301
|
+
const questions = input.questions.flatMap((question) => {
|
|
302
|
+
if (!question || typeof question.question !== "string" || !question.question) return [];
|
|
303
|
+
const options = Array.isArray(question.options)
|
|
304
|
+
? question.options.flatMap((option) => typeof option?.label === "string" && option.label
|
|
305
|
+
? [{ label: option.label, ...(typeof option.description === "string" && option.description ? { description: option.description } : {}) }]
|
|
306
|
+
: [])
|
|
307
|
+
: [];
|
|
308
|
+
return [{ question: question.question, options }];
|
|
309
|
+
});
|
|
310
|
+
if (questions.length > 1) process.stdout.write(JSON.stringify(questions));
|
|
311
|
+
} catch {}
|
|
312
|
+
});
|
|
313
|
+
' 2>/dev/null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
question_is_background() {
|
|
317
|
+
[ -n "$node_bin" ] || return 1
|
|
318
|
+
printf '%s' "$1" | "$node_bin" -e '
|
|
319
|
+
let data = "";
|
|
320
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
321
|
+
process.stdin.on("end", () => {
|
|
322
|
+
try { process.exit((JSON.parse(data) || {}).tool_input?.background === true ? 0 : 1); }
|
|
323
|
+
catch { process.exit(1); }
|
|
324
|
+
});
|
|
325
|
+
' >/dev/null 2>&1
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# Extracts a human-readable command string from a PermissionRequest stdin JSON
|
|
329
|
+
# payload (passed as $1). Prefers the Bash command; falls back to the tool name
|
|
330
|
+
# when the tool has no command field, so the approval toast always shows
|
|
331
|
+
# *something* to approve.
|
|
332
|
+
extract_command() {
|
|
333
|
+
[ -n "$node_bin" ] || return 0
|
|
334
|
+
printf '%s' "$1" | "$node_bin" -e '
|
|
335
|
+
let data = "";
|
|
336
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
337
|
+
process.stdin.on("end", () => {
|
|
338
|
+
let payload;
|
|
339
|
+
try {
|
|
340
|
+
payload = JSON.parse(data) || {};
|
|
341
|
+
} catch {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
let toolInput = payload.tool_input || {};
|
|
345
|
+
if (typeof toolInput === "string") {
|
|
346
|
+
try {
|
|
347
|
+
toolInput = JSON.parse(toolInput);
|
|
348
|
+
} catch {
|
|
349
|
+
toolInput = {};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (!toolInput || typeof toolInput !== "object") toolInput = {};
|
|
353
|
+
const command = toolInput.command;
|
|
354
|
+
if (typeof command === "string" && command) {
|
|
355
|
+
process.stdout.write(command + "\n");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const name = payload.tool_name || "tool";
|
|
359
|
+
const path = toolInput.file_path || toolInput.filePath || toolInput.path;
|
|
360
|
+
process.stdout.write((typeof path === "string" && path ? `${name} ${path}` : name) + "\n");
|
|
361
|
+
});
|
|
362
|
+
' 2>/dev/null
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# Subagent accounting. Kimi subagents share the parent session id, and parallel
|
|
366
|
+
# subagents all carry the default profile name (`coder`), so there is no stable
|
|
367
|
+
# per-child id across SubagentStart/SubagentStop to key markers on. The only
|
|
368
|
+
# correct primitive is a shared active-child COUNT, mutated under a `mkdir`-based
|
|
369
|
+
# exclusive lock so the parallel start/stop hook invocations cannot lose updates
|
|
370
|
+
# (POSIX sh has no atomic increment, but POSIX guarantees `mkdir` is atomic, so
|
|
371
|
+
# this needs no interpreter at all).
|
|
372
|
+
count_file="${state_dir}/pragma-cli-${agent}-${tab}.subagents"
|
|
373
|
+
count_lock="${count_file}.lock"
|
|
374
|
+
|
|
375
|
+
# Mutates the shared subagent count (`inc`/`dec`/`reset`) under the `mkdir` lock
|
|
376
|
+
# and prints the new value.
|
|
377
|
+
subagent_count() {
|
|
378
|
+
tries=0
|
|
379
|
+
until mkdir "$count_lock" 2>/dev/null; do
|
|
380
|
+
tries=$((tries + 1))
|
|
381
|
+
[ "$tries" -lt 200 ] || break
|
|
382
|
+
sleep 0.02
|
|
383
|
+
done
|
|
384
|
+
value=0
|
|
385
|
+
[ -f "$count_file" ] && value="$(cat "$count_file" 2>/dev/null)"
|
|
386
|
+
case "$value" in
|
|
387
|
+
'' | *[!0-9]*) value=0 ;;
|
|
388
|
+
esac
|
|
389
|
+
case "$1" in
|
|
390
|
+
inc) value=$((value + 1)) ;;
|
|
391
|
+
dec)
|
|
392
|
+
if [ "$value" -gt 0 ]; then
|
|
393
|
+
value=$((value - 1))
|
|
394
|
+
else
|
|
395
|
+
value=0
|
|
396
|
+
fi
|
|
397
|
+
;;
|
|
398
|
+
*) value=0 ;;
|
|
399
|
+
esac
|
|
400
|
+
printf '%s' "$value" >"$count_file"
|
|
401
|
+
rmdir "$count_lock" 2>/dev/null
|
|
402
|
+
printf '%s' "$value"
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
track_subagent() {
|
|
406
|
+
subagent_count inc >/dev/null
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
untrack_subagent() {
|
|
410
|
+
subagent_count dec >/dev/null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
tracked_subagent_count() {
|
|
414
|
+
count=0
|
|
415
|
+
[ -f "$count_file" ] && count="$(cat "$count_file" 2>/dev/null)"
|
|
416
|
+
case "$count" in
|
|
417
|
+
''|*[!0-9]*) count=0 ;;
|
|
418
|
+
esac
|
|
419
|
+
printf '%s' "$count"
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
has_tracked_subagents() {
|
|
423
|
+
[ "$(tracked_subagent_count)" -gt 0 ]
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
clear_subagents() {
|
|
427
|
+
subagent_count reset >/dev/null
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
# Clears the in-flight turn marker and every turn-scoped state file. Leaves the
|
|
431
|
+
# session pin and the subagent accounting alone (callers decide their fate).
|
|
432
|
+
clear_turn() {
|
|
433
|
+
rm -f "$marker" "$turn_session_file"
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
input="$(cat)"
|
|
437
|
+
hook_event_name="$(json_field hook_event_name "$input")"
|
|
438
|
+
hook_session_id="$(json_field session_id "$input")"
|
|
439
|
+
|
|
440
|
+
# Subagent lifecycle first: those hooks share the parent session id, so they
|
|
441
|
+
# are accounted for explicitly and never fall through to the parent status
|
|
442
|
+
# machine below. Parallel subagents all report the default profile name, so the
|
|
443
|
+
# count (not a per-name marker) is the discriminator.
|
|
444
|
+
if [ "${1:-}" = "subagent-start" ]; then
|
|
445
|
+
track_subagent
|
|
446
|
+
[ -f "$marker" ] || printf '%s' "$$-$(date +%s)" >"$marker"
|
|
447
|
+
message system "Kimi started a subagent"
|
|
448
|
+
report started
|
|
449
|
+
exit 0
|
|
450
|
+
fi
|
|
451
|
+
if [ "${1:-}" = "subagent-stop" ]; then
|
|
452
|
+
untrack_subagent
|
|
453
|
+
exit 0
|
|
454
|
+
fi
|
|
455
|
+
|
|
456
|
+
# Pin this tab to the parent session established by SessionStart /
|
|
457
|
+
# UserPromptSubmit and reject every event from a different session before it can
|
|
458
|
+
# alter parent status.
|
|
459
|
+
case "$hook_event_name" in
|
|
460
|
+
SessionStart|UserPromptSubmit)
|
|
461
|
+
[ -z "$hook_session_id" ] || printf '%s' "$hook_session_id" >"$session_file"
|
|
462
|
+
;;
|
|
463
|
+
esac
|
|
464
|
+
if [ -n "$hook_session_id" ] && [ -f "$session_file" ]; then
|
|
465
|
+
parent_session_id="$(cat "$session_file" 2>/dev/null)"
|
|
466
|
+
[ -z "$parent_session_id" ] || [ "$hook_session_id" = "$parent_session_id" ] || exit 0
|
|
467
|
+
fi
|
|
468
|
+
|
|
469
|
+
case "${1:-}" in
|
|
470
|
+
started)
|
|
471
|
+
# A new turn is in flight. Tag the marker with a unique token, report
|
|
472
|
+
# running, and surface the prompt as the chat's user bubble (Kimi's prompt
|
|
473
|
+
# is a ContentPart array, so the text parts must be joined first).
|
|
474
|
+
token="$$-$(date +%s)"
|
|
475
|
+
printf '%s' "$token" >"$marker"
|
|
476
|
+
printf '%s' "$hook_session_id" >"$turn_session_file"
|
|
477
|
+
report started
|
|
478
|
+
prompt="$(prompt_text "$(json_raw prompt "$input")")"
|
|
479
|
+
if [ -n "$prompt" ]; then
|
|
480
|
+
content_message user "$prompt"
|
|
481
|
+
else
|
|
482
|
+
message assistant "Kimi turn started"
|
|
483
|
+
fi
|
|
484
|
+
report_session_name "$prompt"
|
|
485
|
+
;;
|
|
486
|
+
stopped)
|
|
487
|
+
# Stop fires only for genuine completions. A Stop arriving while a subagent
|
|
488
|
+
# is still tracked is that subagent's own turn ending (kimi subagents share
|
|
489
|
+
# the parent session id) -- the session keeps working, so stay on `started`.
|
|
490
|
+
if has_tracked_subagents; then
|
|
491
|
+
[ -f "$marker" ] || printf '%s' "$$-$(date +%s)" >"$marker"
|
|
492
|
+
report started
|
|
493
|
+
message assistant "Kimi is waiting on subagents"
|
|
494
|
+
exit 0
|
|
495
|
+
fi
|
|
496
|
+
if [ ! -f "$marker" ]; then
|
|
497
|
+
# No turn was ever started for this tab: a bare Stop must not create a
|
|
498
|
+
# phantom done dot.
|
|
499
|
+
exit 0
|
|
500
|
+
fi
|
|
501
|
+
clear_turn
|
|
502
|
+
report stopped
|
|
503
|
+
reply_session_id="${hook_session_id:-$(cat "$session_file" 2>/dev/null)}"
|
|
504
|
+
reply="$(last_assistant_text "$reply_session_id")"
|
|
505
|
+
if [ -n "$reply" ]; then
|
|
506
|
+
content_message assistant "$reply"
|
|
507
|
+
else
|
|
508
|
+
message assistant "Kimi turn completed"
|
|
509
|
+
fi
|
|
510
|
+
;;
|
|
511
|
+
failed)
|
|
512
|
+
# StopFailure: the turn ended on an API error, so it never completed. Clear
|
|
513
|
+
# rather than report done. A subagent's failure while the parent still works
|
|
514
|
+
# stays on `started` (the Agent tool returns the error to the parent).
|
|
515
|
+
detail="$(json_field error_message "$input")"
|
|
516
|
+
[ -n "$detail" ] || detail="$(json_field errorMessage "$input")"
|
|
517
|
+
[ -n "$detail" ] || detail="unknown error"
|
|
518
|
+
if has_tracked_subagents; then
|
|
519
|
+
[ -f "$marker" ] || printf '%s' "$$-$(date +%s)" >"$marker"
|
|
520
|
+
report started
|
|
521
|
+
message system "Kimi subagent failed ($detail)"
|
|
522
|
+
exit 0
|
|
523
|
+
fi
|
|
524
|
+
if [ -f "$marker" ]; then
|
|
525
|
+
clear_turn
|
|
526
|
+
report cleared
|
|
527
|
+
message system "Kimi turn failed ($detail)"
|
|
528
|
+
fi
|
|
529
|
+
;;
|
|
530
|
+
interrupted)
|
|
531
|
+
# Interrupt: the user cancelled the running turn (Esc / Ctrl+C). Stop never
|
|
532
|
+
# fires for a cancel, so this is the whole cancel signal -- no watcher.
|
|
533
|
+
if [ -f "$marker" ]; then
|
|
534
|
+
clear_turn
|
|
535
|
+
report cleared
|
|
536
|
+
message system "Kimi turn interrupted"
|
|
537
|
+
fi
|
|
538
|
+
;;
|
|
539
|
+
cleared)
|
|
540
|
+
# SessionStart fires for a brand new session while the previous one's
|
|
541
|
+
# SessionEnd and the new session's first prompt can race. Skip the clear when
|
|
542
|
+
# the in-flight turn already belongs to this same session.
|
|
543
|
+
if [ "$hook_event_name" = "SessionStart" ] && [ -f "$marker" ] &&
|
|
544
|
+
[ -n "$hook_session_id" ] &&
|
|
545
|
+
[ "$(cat "$turn_session_file" 2>/dev/null)" = "$hook_session_id" ]; then
|
|
546
|
+
exit 0
|
|
547
|
+
fi
|
|
548
|
+
clear_turn
|
|
549
|
+
clear_subagents
|
|
550
|
+
if [ "$hook_event_name" = "SessionEnd" ]; then
|
|
551
|
+
rm -f "$session_file" "$named_file"
|
|
552
|
+
fi
|
|
553
|
+
report cleared
|
|
554
|
+
;;
|
|
555
|
+
running)
|
|
556
|
+
# PostToolUse / PostToolUseFailure: a tool just finished mid-turn. Re-assert
|
|
557
|
+
# `running` so a lingering `attention` (raised for a question or approval
|
|
558
|
+
# Kimi answered in its own TUI) drops back to "in progress" at once instead
|
|
559
|
+
# of staying stuck until Stop. The marker is deliberately left untouched:
|
|
560
|
+
# this is the same turn `started` set up.
|
|
561
|
+
if [ -f "$marker" ]; then
|
|
562
|
+
report started
|
|
563
|
+
# PostToolUse is the only hook that carries content: kimi's `Stop` has no
|
|
564
|
+
# reply text, so the tool result (`tool_output`, capped at 2000 chars by
|
|
565
|
+
# kimi) is the one channel for surfacing what the turn produced. Report it
|
|
566
|
+
# as an assistant message so the conversation shows the result and the
|
|
567
|
+
# verify harness can match on it (e.g. command-no-permission's timestamp).
|
|
568
|
+
tool_output="$(json_field tool_output "$input")"
|
|
569
|
+
if [ -n "$tool_output" ]; then
|
|
570
|
+
content_message assistant "$tool_output"
|
|
571
|
+
else
|
|
572
|
+
message tool "Kimi tool finished"
|
|
573
|
+
fi
|
|
574
|
+
fi
|
|
575
|
+
;;
|
|
576
|
+
permission)
|
|
577
|
+
# PermissionRequest: Kimi is asking to approve a tool in its own TUI. The
|
|
578
|
+
# hook is fire-and-forget (the verdict comes from the TUI's requestApproval
|
|
579
|
+
# RPC, never from this hook), so this raises a command attention for the
|
|
580
|
+
# user to look at the terminal; PostToolUse clears it once the turn resumes.
|
|
581
|
+
# The attention must carry a request-id (stream-integrity requires command
|
|
582
|
+
# attention to include command/requestId); it is a correlation token, not a
|
|
583
|
+
# brokered verdict, because Kimi answers in its own TUI.
|
|
584
|
+
if [ -f "$marker" ]; then
|
|
585
|
+
command_text="$(extract_command "$input")"
|
|
586
|
+
[ -n "$command_text" ] || command_text="Kimi needs approval"
|
|
587
|
+
request_id="${agent}-${tab}-$(date +%s)-$$"
|
|
588
|
+
report attention --kind command --command "$command_text" --request-id "$request_id"
|
|
589
|
+
message system "Kimi needs approval"
|
|
590
|
+
fi
|
|
591
|
+
;;
|
|
592
|
+
question)
|
|
593
|
+
# PreToolUse on `AskUserQuestion`: Kimi's TUI owns the answer UI (the hook
|
|
594
|
+
# is veto-only and cannot return an answer), so this raises a question
|
|
595
|
+
# attention for the user to look at the terminal. Like command attention it
|
|
596
|
+
# must carry both a question text and a request-id for stream-integrity.
|
|
597
|
+
if [ -f "$marker" ]; then
|
|
598
|
+
question_is_background "$input" && exit 0
|
|
599
|
+
qtext="$(question_text "$input")"
|
|
600
|
+
qopts="$(question_options "$input")"
|
|
601
|
+
qjson="$(question_json "$input")"
|
|
602
|
+
request_id="${agent}-${tab}-$(date +%s)-$$"
|
|
603
|
+
if [ -n "$qjson" ]; then
|
|
604
|
+
report attention --kind question --questions "$qjson" --request-id "$request_id"
|
|
605
|
+
message system "Kimi is asking questions"
|
|
606
|
+
elif [ -n "$qtext" ] && [ -n "$qopts" ]; then
|
|
607
|
+
report attention --kind question --question "$qtext" --options "$qopts" --request-id "$request_id"
|
|
608
|
+
content_message system "$qtext"
|
|
609
|
+
elif [ -n "$qtext" ]; then
|
|
610
|
+
report attention --kind question --question "$qtext" --request-id "$request_id"
|
|
611
|
+
content_message system "$qtext"
|
|
612
|
+
else
|
|
613
|
+
report attention --kind question --question "Kimi is asking a question" --request-id "$request_id"
|
|
614
|
+
message system "Kimi is asking a question"
|
|
615
|
+
fi
|
|
616
|
+
fi
|
|
617
|
+
;;
|
|
618
|
+
esac
|
|
619
|
+
|
|
620
|
+
exit 0
|
package/kimi.plugin.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pragma-kimi",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Report Kimi Code agent status into Pragma via pragma-cli.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Pragma"
|
|
7
|
+
},
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": ["pragma", "agent"],
|
|
10
|
+
"hooks": [
|
|
11
|
+
{
|
|
12
|
+
"event": "SessionStart",
|
|
13
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" cleared",
|
|
14
|
+
"timeout": 10
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"event": "SessionEnd",
|
|
18
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" cleared",
|
|
19
|
+
"timeout": 10
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"event": "UserPromptSubmit",
|
|
23
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" started",
|
|
24
|
+
"timeout": 10
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"event": "Stop",
|
|
28
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" stopped",
|
|
29
|
+
"timeout": 20
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"event": "StopFailure",
|
|
33
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" failed",
|
|
34
|
+
"timeout": 10
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"event": "Interrupt",
|
|
38
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" interrupted",
|
|
39
|
+
"timeout": 10
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"event": "SubagentStart",
|
|
43
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" subagent-start",
|
|
44
|
+
"timeout": 10
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"event": "SubagentStop",
|
|
48
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" subagent-stop",
|
|
49
|
+
"timeout": 10
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"event": "PostToolUse",
|
|
53
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" running",
|
|
54
|
+
"timeout": 10
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"event": "PermissionRequest",
|
|
58
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" permission",
|
|
59
|
+
"timeout": 10
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"event": "PreToolUse",
|
|
63
|
+
"matcher": "AskUserQuestion",
|
|
64
|
+
"command": "sh \"$KIMI_PLUGIN_ROOT/hooks/report.sh\" question",
|
|
65
|
+
"timeout": 10
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|