@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.
@@ -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
@@ -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
+ }