clay-server 2.46.0 → 2.47.0-beta.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.
@@ -0,0 +1,1156 @@
1
+ // YOKE Kiro Adapter
2
+ // -----------------
3
+ // Implements the YOKE interface using the Agent Client Protocol (ACP) exposed
4
+ // by `kiro-cli acp`. Bidirectional JSON-RPC 2.0 over stdin/stdout enables
5
+ // streaming output and interactive tool-permission flows.
6
+ //
7
+ // ACP turn lifecycle (simpler than Codex app-server):
8
+ // session/new -> { sessionId, modes, models }
9
+ // session/set_config_option -> select model and force supervised permissions
10
+ // session/prompt (req) -> resolves with { stopReason } when the turn ends
11
+ // ...meanwhile the agent streams session/update notifications and may send
12
+ // session/request_permission requests for tool approvals.
13
+ // session/cancel (notif) -> interrupt the active turn
14
+
15
+ var path = require("path");
16
+ var fs = require("fs");
17
+ var { execFile } = require("child_process");
18
+ var { KiroAcpServer, findKiroPath } = require("../kiro-acp-server");
19
+ var { KIRO_DEFAULTS } = require("../../kiro-defaults");
20
+
21
+ // --- Claude skill discovery ---
22
+ // Kiro exposes its own agents/skills, but Clay users share $<skill-name>
23
+ // references from ~/.claude/skills. We mirror the Codex adapter so the same
24
+ // references resolve regardless of runtime.
25
+ function discoverClaudeSkills(cwd) {
26
+ var skills = {};
27
+ var REAL_HOME;
28
+ try { REAL_HOME = require("../../config").REAL_HOME; } catch (e) { REAL_HOME = require("os").homedir(); }
29
+ var dirs = [
30
+ path.join(REAL_HOME, ".claude", "skills"),
31
+ path.join(cwd || "", ".claude", "skills"),
32
+ ];
33
+ for (var d = 0; d < dirs.length; d++) {
34
+ var base = dirs[d];
35
+ if (!base) continue;
36
+ var entries;
37
+ try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch (e) { continue; }
38
+ for (var i = 0; i < entries.length; i++) {
39
+ var entry = entries[i];
40
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
41
+ var skillMd = path.join(base, entry.name, "SKILL.md");
42
+ try {
43
+ fs.accessSync(skillMd, fs.constants.R_OK);
44
+ skills[entry.name] = skillMd;
45
+ } catch (e) {}
46
+ }
47
+ }
48
+ return skills;
49
+ }
50
+
51
+ var _uuidCounter = 0;
52
+ function generateUuid() {
53
+ var ts = Date.now().toString(36);
54
+ var cnt = (++_uuidCounter).toString(36);
55
+ var rnd = Math.random().toString(36).substring(2, 8);
56
+ return "kiro-" + ts + "-" + cnt + "-" + rnd;
57
+ }
58
+
59
+ function waitMs(ms) {
60
+ return new Promise(function(resolve) { setTimeout(resolve, ms); });
61
+ }
62
+
63
+ function waitForProcessExit(proc, timeoutMs) {
64
+ return new Promise(function(resolve) {
65
+ if (!proc) { resolve(true); return; }
66
+ if (proc.exitCode !== null || proc.signalCode !== null) { resolve(true); return; }
67
+ var done = false, timer = null;
68
+ function cleanup() {
69
+ if (done) return;
70
+ done = true;
71
+ if (timer) clearTimeout(timer);
72
+ proc.removeListener("exit", onDone);
73
+ proc.removeListener("close", onDone);
74
+ }
75
+ function onDone() { cleanup(); resolve(true); }
76
+ proc.once("exit", onDone);
77
+ proc.once("close", onDone);
78
+ timer = setTimeout(function() { cleanup(); resolve(false); }, timeoutMs || 5000);
79
+ });
80
+ }
81
+
82
+ function createShutdownError() {
83
+ var err = new Error("Kiro adapter is shutting down, retry shortly");
84
+ err.code = "KIRO_ADAPTER_SHUTTING_DOWN";
85
+ return err;
86
+ }
87
+
88
+ // Detect Kiro "not logged in" errors from an error object or message string.
89
+ function isKiroAuthError(text, errObj) {
90
+ if (errObj && errObj.kiroErrorInfo === "unauthorized") return true;
91
+ return /not logged in|expired token|token has expired|please (?:sign in|log ?in) again|reauthenticate|kiro-cli login|no valid credentials|unauthorized|forbidden|auth refresh callback failed|failed to verify authentication|\b401\b/i.test(String(text || ""));
92
+ }
93
+
94
+ // Map an ACP tool kind to a Clay-facing tool name so the UI can pick an icon.
95
+ function toolNameForKind(kind, title) {
96
+ switch (kind) {
97
+ case "execute": return "Bash";
98
+ case "read": return "Read";
99
+ case "edit": return "Edit";
100
+ case "delete": return "Edit";
101
+ case "move": return "Edit";
102
+ case "search": return "Grep";
103
+ case "fetch": return "WebFetch";
104
+ case "think": return "Think";
105
+ default: return title || "Tool";
106
+ }
107
+ }
108
+
109
+ function normalizePlanStatus(status) {
110
+ if (status === "in_progress" || status === "inProgress") return "in_progress";
111
+ if (status === "completed") return "completed";
112
+ return "pending";
113
+ }
114
+
115
+ // Fetch the model catalog from the CLI (JSON) so the picker mirrors Claude's
116
+ // dynamic listing. Internal/deprecated entries are filtered out for a clean UX.
117
+ function fetchModelsViaCli(binaryPath, cwd) {
118
+ return new Promise(function(resolve) {
119
+ execFile(binaryPath, ["chat", "--list-models", "--format", "json"], {
120
+ timeout: 20000,
121
+ cwd: cwd || process.cwd(),
122
+ maxBuffer: 4 * 1024 * 1024,
123
+ }, function(err, stdout) {
124
+ if (err || !stdout) { resolve(null); return; }
125
+ try {
126
+ var parsed = JSON.parse(stdout);
127
+ var models = [];
128
+ var contextWindows = {};
129
+ var list = (parsed && parsed.models) || [];
130
+ for (var i = 0; i < list.length; i++) {
131
+ var m = list[i];
132
+ var desc = m.description || "";
133
+ if (/\[Internal\]|\[Deprecated\]/i.test(desc)) continue;
134
+ if (m.model_id) {
135
+ models.push(m.model_id);
136
+ if (typeof m.context_window_tokens === "number") contextWindows[m.model_id] = m.context_window_tokens;
137
+ }
138
+ }
139
+ resolve({ models: models, defaultModel: parsed.default_model || "auto", contextWindows: contextWindows });
140
+ } catch (e) {
141
+ resolve(null);
142
+ }
143
+ });
144
+ });
145
+ }
146
+
147
+ // Kiro's v3 ACP engine delegates token refresh to the host through the
148
+ // _kiro/auth/getAccessToken request. The CLI exposes a narrow internal command
149
+ // that returns the active profile's refreshed KAS token as JSON.
150
+ function fetchKasTokenViaCli(binaryPath, cwd) {
151
+ return new Promise(function(resolve, reject) {
152
+ execFile(binaryPath, ["chat", "_", "get-kas-token"], {
153
+ timeout: 20000,
154
+ cwd: cwd || process.cwd(),
155
+ maxBuffer: 1024 * 1024,
156
+ }, function(err, stdout) {
157
+ if (err) { reject(err); return; }
158
+ try {
159
+ var parsed = JSON.parse(stdout);
160
+ if (!parsed || parsed.kind !== "getKasToken" || !parsed.data || !parsed.data.accessToken) {
161
+ throw new Error("Unexpected Kiro token response");
162
+ }
163
+ resolve(parsed.data);
164
+ } catch (e) {
165
+ reject(new Error("Failed to parse Kiro access token response: " + e.message));
166
+ }
167
+ });
168
+ });
169
+ }
170
+
171
+ // --- Event flattening ---
172
+ // Converts ACP session/update payloads into flat yokeType events, matching the
173
+ // format the rest of Clay consumes (same shapes the Codex adapter emits).
174
+ function flattenUpdate(update, state) {
175
+ var events = [];
176
+ if (!update) return events;
177
+ var type = update.sessionUpdate;
178
+
179
+ // Streaming assistant text
180
+ if (type === "agent_message_chunk") {
181
+ var text = update.content && typeof update.content.text === "string" ? update.content.text : "";
182
+ if (!state.textBlockOpen) {
183
+ state.textBlockOpen = true;
184
+ state.blockCounter++;
185
+ state.textBlockId = "blk_" + state.blockCounter;
186
+ events.push({ yokeType: "text_start", blockId: state.textBlockId });
187
+ }
188
+ if (text) {
189
+ events.push({ yokeType: "text_delta", blockId: state.textBlockId, text: text });
190
+ }
191
+ return events;
192
+ }
193
+
194
+ // Streaming reasoning / thinking
195
+ if (type === "agent_thought_chunk") {
196
+ var think = update.content && typeof update.content.text === "string" ? update.content.text : "";
197
+ if (!state.thinkBlockOpen) {
198
+ state.thinkBlockOpen = true;
199
+ state.blockCounter++;
200
+ state.thinkBlockId = "blk_" + state.blockCounter;
201
+ events.push({ yokeType: "thinking_start", blockId: state.thinkBlockId });
202
+ }
203
+ if (think) {
204
+ events.push({ yokeType: "thinking_delta", blockId: state.thinkBlockId, text: think });
205
+ }
206
+ return events;
207
+ }
208
+
209
+ // Tool call announced
210
+ if (type === "tool_call") {
211
+ var callId = update.toolCallId;
212
+ var toolName = toolNameForKind(update.kind, update.title);
213
+ // Cache kind/rawInput so the permission handler (whose request payload only
214
+ // carries { toolCallId, title }) can pass a canonical tool name + input to
215
+ // canUseTool. Clay's permission whitelist keys on canonical names.
216
+ if (callId) {
217
+ state.toolMeta[callId] = { kind: update.kind, title: update.title, rawInput: update.rawInput || {} };
218
+ }
219
+ if (callId && !state.toolBlocks[callId]) {
220
+ state.blockCounter++;
221
+ state.toolBlocks[callId] = "blk_" + state.blockCounter;
222
+ var blockId = state.toolBlocks[callId];
223
+ events.push({ yokeType: "tool_start", blockId: blockId, toolId: callId, toolName: toolName });
224
+ events.push({
225
+ yokeType: "tool_executing",
226
+ blockId: blockId,
227
+ toolId: callId,
228
+ toolName: toolName,
229
+ input: update.rawInput || {},
230
+ });
231
+ }
232
+ accumulateToolContent(state, callId, update);
233
+ if (update.status === "completed" || update.status === "failed") {
234
+ events.push({
235
+ yokeType: "tool_result",
236
+ toolId: callId,
237
+ blockId: state.toolBlocks[callId],
238
+ content: finalToolContent(state, callId, update),
239
+ isError: update.status === "failed",
240
+ });
241
+ }
242
+ return events;
243
+ }
244
+
245
+ // Tool call progress / completion.
246
+ // Kiro splits tool output across events: an interim tool_call_update carries
247
+ // `content` (no status), and a later one has status:"completed" but empty
248
+ // content (the output lives in `rawOutput`). Accumulate content across events
249
+ // and fall back to rawOutput at completion so tool_result is never empty.
250
+ if (type === "tool_call_update") {
251
+ var updId = update.toolCallId;
252
+ if (updId && !state.toolBlocks[updId]) {
253
+ // Result arrived before we saw the tool_call (rare) — synthesize a block.
254
+ state.blockCounter++;
255
+ state.toolBlocks[updId] = "blk_" + state.blockCounter;
256
+ events.push({ yokeType: "tool_start", blockId: state.toolBlocks[updId], toolId: updId, toolName: update.title || "Tool" });
257
+ }
258
+ accumulateToolContent(state, updId, update);
259
+ if (update.status === "completed" || update.status === "failed") {
260
+ events.push({
261
+ yokeType: "tool_result",
262
+ toolId: updId,
263
+ blockId: state.toolBlocks[updId],
264
+ content: finalToolContent(state, updId, update),
265
+ isError: update.status === "failed",
266
+ });
267
+ }
268
+ return events;
269
+ }
270
+
271
+ // Plan updates
272
+ if (type === "plan") {
273
+ var entries = Array.isArray(update.entries) ? update.entries : [];
274
+ events.push({
275
+ yokeType: "plan_updated",
276
+ title: "Plan",
277
+ explanation: "",
278
+ plan: entries.map(function(e) {
279
+ return { step: e.content || "", status: normalizePlanStatus(e.status) };
280
+ }),
281
+ });
282
+ return events;
283
+ }
284
+
285
+ // Token usage
286
+ if (type === "usage_update") {
287
+ if (typeof update.used === "number") state.lastInputTokens = update.used;
288
+ if (typeof update.size === "number") state.contextWindow = update.size;
289
+ return events;
290
+ }
291
+
292
+ // Kiro v3 reports context consumption as a percentage in session_info_update
293
+ // instead of the v2 usage_update token counters.
294
+ if (type === "session_info_update") {
295
+ var kiroMeta = update._meta && update._meta.kiro;
296
+ if (kiroMeta && kiroMeta.kind === "context_usage") {
297
+ var usage = kiroMeta.contextUsage;
298
+ var percentage = usage && usage.usagePercentage;
299
+ if (typeof percentage !== "number") percentage = kiroMeta.usagePercentage;
300
+ if (typeof percentage === "number" && state.contextWindow) {
301
+ state.lastInputTokens = Math.round(state.contextWindow * percentage / 100);
302
+ }
303
+ }
304
+ return events;
305
+ }
306
+
307
+ // Unknown update: pass through for observability.
308
+ events.push({ yokeType: "runtime_specific", vendor: "kiro", eventType: "session/update:" + type, raw: update });
309
+ return events;
310
+ }
311
+
312
+ // ACP tool content is an array of { type: "content"|"diff", ... }. Flatten it
313
+ // into a display string for the tool result bubble.
314
+ function extractToolContent(content) {
315
+ if (!Array.isArray(content)) return "";
316
+ var parts = [];
317
+ for (var i = 0; i < content.length; i++) {
318
+ var c = content[i];
319
+ if (!c) continue;
320
+ if (c.type === "content" && c.content && typeof c.content.text === "string") {
321
+ parts.push(c.content.text);
322
+ } else if (c.type === "diff") {
323
+ var header = c.path ? ("--- " + c.path + "\n") : "";
324
+ parts.push(header + (c.newText || ""));
325
+ } else if (typeof c.text === "string") {
326
+ parts.push(c.text);
327
+ }
328
+ }
329
+ return parts.join("\n");
330
+ }
331
+
332
+ // Extract text from a Kiro `rawOutput` object. Command execution reports it as
333
+ // { items: [{ Json: { stdout, stderr, exit_status } }] }; other tools may nest
334
+ // text differently, so we walk defensively.
335
+ function extractRawOutput(rawOutput) {
336
+ if (!rawOutput) return "";
337
+ var items = rawOutput.items;
338
+ if (!Array.isArray(items)) {
339
+ if (typeof rawOutput === "string") return rawOutput;
340
+ if (typeof rawOutput.output === "string") return rawOutput.output;
341
+ if (typeof rawOutput.message === "string") return rawOutput.message;
342
+ return "";
343
+ }
344
+ var parts = [];
345
+ for (var i = 0; i < items.length; i++) {
346
+ var it = items[i];
347
+ if (!it) continue;
348
+ var j = it.Json || it.json || it;
349
+ if (j && typeof j === "object") {
350
+ if (typeof j.stdout === "string" && j.stdout) parts.push(j.stdout);
351
+ if (typeof j.stderr === "string" && j.stderr) parts.push(j.stderr);
352
+ if (!j.stdout && !j.stderr && typeof j.text === "string") parts.push(j.text);
353
+ } else if (typeof it === "string") {
354
+ parts.push(it);
355
+ }
356
+ }
357
+ return parts.join("").replace(/\n+$/, "");
358
+ }
359
+
360
+ // Accumulate content chunks for a tool call across the multiple tool_call_update
361
+ // events Kiro emits (interim content, then a completed event with empty content).
362
+ function accumulateToolContent(state, callId, update) {
363
+ if (!callId) return;
364
+ var chunk = extractToolContent(update.content);
365
+ if (chunk) {
366
+ state.toolContent[callId] = (state.toolContent[callId] || "") + (state.toolContent[callId] ? "\n" : "") + chunk;
367
+ }
368
+ }
369
+
370
+ // Best available content for a finished tool call: accumulated streamed content,
371
+ // else this event's content, else the structured rawOutput.
372
+ function finalToolContent(state, callId, update) {
373
+ var acc = callId && state.toolContent[callId];
374
+ if (acc) return acc;
375
+ var direct = extractToolContent(update.content);
376
+ if (direct) return direct;
377
+ return extractRawOutput(update.rawOutput);
378
+ }
379
+
380
+ // --- QueryHandle ---
381
+
382
+ function createKiroQueryHandle(acp, queryOpts) {
383
+ var abortController = queryOpts.abortController;
384
+ var systemPrompt = queryOpts.systemPrompt || "";
385
+ var canUseTool = queryOpts.canUseTool || null;
386
+ var onFinished = queryOpts.onFinished || null;
387
+
388
+ function isCancelled() {
389
+ return state.aborted || (abortController && abortController.signal && abortController.signal.aborted);
390
+ }
391
+
392
+ var state = {
393
+ blockCounter: 0,
394
+ sessionId: queryOpts.resumeSessionId || null,
395
+ model: queryOpts.model || "auto",
396
+ engine: queryOpts.engine || KIRO_DEFAULTS.engine,
397
+ lastInputTokens: null,
398
+ contextWindow: queryOpts.contextWindow || null,
399
+ done: false,
400
+ aborted: false,
401
+ loopStarted: false,
402
+ loadingSession: false,
403
+ // per-turn block tracking (reset each turn)
404
+ textBlockOpen: false,
405
+ textBlockId: null,
406
+ thinkBlockOpen: false,
407
+ thinkBlockId: null,
408
+ toolBlocks: {},
409
+ toolMeta: {},
410
+ toolContent: {},
411
+ };
412
+
413
+ // Async iterator plumbing
414
+ var eventBuffer = [];
415
+ var eventWaiting = null;
416
+ var iteratorDone = false;
417
+ var finishedNotified = false;
418
+
419
+ function notifyFinished() {
420
+ if (finishedNotified) return;
421
+ finishedNotified = true;
422
+ if (typeof onFinished === "function") {
423
+ try { onFinished(); } catch (e) { console.error("[yoke/kiro] onFinished error:", e.message || e); }
424
+ }
425
+ }
426
+
427
+ function pushEvent(evt) {
428
+ if (iteratorDone) return;
429
+ if (eventWaiting) {
430
+ var resolve = eventWaiting;
431
+ eventWaiting = null;
432
+ resolve({ value: evt, done: false });
433
+ } else {
434
+ eventBuffer.push(evt);
435
+ }
436
+ }
437
+
438
+ function endIterator() {
439
+ iteratorDone = true;
440
+ if (eventWaiting) {
441
+ var resolve = eventWaiting;
442
+ eventWaiting = null;
443
+ resolve({ value: undefined, done: true });
444
+ }
445
+ notifyFinished();
446
+ }
447
+
448
+ // Multi-turn message queue
449
+ var messageQueue = [];
450
+ var messageWaiting = null;
451
+ var messageQueueEnded = false;
452
+
453
+ function pushMessageToQueue(msg) {
454
+ if (messageQueueEnded) return;
455
+ if (messageWaiting) {
456
+ var resolve = messageWaiting;
457
+ messageWaiting = null;
458
+ resolve(msg);
459
+ } else {
460
+ messageQueue.push(msg);
461
+ }
462
+ }
463
+ function waitForMessage() {
464
+ if (messageQueue.length > 0) return Promise.resolve(messageQueue.shift());
465
+ if (messageQueueEnded) return Promise.resolve(null);
466
+ return new Promise(function(resolve) { messageWaiting = resolve; });
467
+ }
468
+
469
+ // --- ACP event handler ---
470
+ function isApproved(decision) {
471
+ if (!decision) return false;
472
+ if (decision === true) return true;
473
+ if (decision.behavior === "allow") return true;
474
+ return false;
475
+ }
476
+
477
+ function handleServerEvent(msg) {
478
+ var method = msg.method;
479
+ var params = msg.params || {};
480
+
481
+ // Routing by sessionId happens in KiroAcpServer._handleMessage, which also
482
+ // guarantees unroutable requests get an error response. Do not add a silent
483
+ // sessionId filter here: dropping a request without calling acp.respond()
484
+ // blocks kiro-cli until the session/prompt timeout.
485
+
486
+ // Tool permission request (server -> client, has an id we must answer)
487
+ if (method === "session/request_permission") {
488
+ var tc = params.toolCall || {};
489
+ var options = params.options || [];
490
+ function pickOption(kinds) {
491
+ for (var i = 0; i < options.length; i++) {
492
+ if (kinds.indexOf(options[i].kind) !== -1) return options[i].optionId;
493
+ }
494
+ return null;
495
+ }
496
+ var allowId = pickOption(["allow_once", "allow_always"]) || (options[0] && options[0].optionId);
497
+ var rejectId = pickOption(["reject_once", "reject_always"]) || (options[options.length - 1] && options[options.length - 1].optionId);
498
+
499
+ if (isCancelled()) {
500
+ acp.respond(msg.id, { outcome: { outcome: "cancelled" } });
501
+ return;
502
+ }
503
+ if (canUseTool) {
504
+ // The permission request payload carries only { toolCallId, title }.
505
+ // Recover kind + rawInput from the tool_call notification we cached.
506
+ var meta = (tc.toolCallId && state.toolMeta[tc.toolCallId]) || {};
507
+ var toolName = toolNameForKind(tc.kind || meta.kind, tc.title || meta.title);
508
+ var toolInput = tc.rawInput || meta.rawInput || { title: tc.title || meta.title };
509
+ canUseTool(toolName, toolInput, {}).then(function(decision) {
510
+ acp.respond(msg.id, { outcome: { outcome: "selected", optionId: isApproved(decision) ? allowId : rejectId } });
511
+ }).catch(function(err) {
512
+ console.error("[yoke/kiro] canUseTool error:", err.message);
513
+ acp.respond(msg.id, { outcome: { outcome: "selected", optionId: rejectId } });
514
+ });
515
+ } else {
516
+ // No approver wired up. Deny: Clay is the only thing standing between
517
+ // the agent and the user's filesystem, so absence of an approver must
518
+ // never mean approval.
519
+ console.warn("[yoke/kiro] permission request with no canUseTool callback, denying");
520
+ acp.respond(msg.id, { outcome: { outcome: "selected", optionId: rejectId } });
521
+ }
522
+ return;
523
+ }
524
+
525
+ // Session updates (streaming)
526
+ if (method === "session/update") {
527
+ if (isCancelled()) return;
528
+ // session/load replays persisted history before resolving. Clay already
529
+ // renders that history from its local session file, so forwarding replay
530
+ // chunks would duplicate old assistant text as part of the new turn.
531
+ if (state.loadingSession) return;
532
+ var yokeEvents = flattenUpdate(params.update, state);
533
+ for (var i = 0; i < yokeEvents.length; i++) pushEvent(yokeEvents[i]);
534
+ return;
535
+ }
536
+
537
+ // Synthetic auth error from the transport layer
538
+ if (method === "_kiro/error") {
539
+ if (isKiroAuthError(params.error && params.error.message, params.error)) {
540
+ pushEvent({ yokeType: "auth_required", vendor: "kiro" });
541
+ } else {
542
+ pushEvent({ yokeType: "error", text: (params.error && params.error.message) || "Kiro error" });
543
+ }
544
+ return;
545
+ }
546
+
547
+ // _kiro.dev/* notifications (commands, metadata, subagents, mcp status) are
548
+ // informational; ignore them quietly to avoid noise.
549
+ }
550
+
551
+ // Close the currently open streaming blocks at turn end.
552
+ function closeOpenBlocks() {
553
+ if (state.thinkBlockOpen) {
554
+ pushEvent({ yokeType: "thinking_stop", blockId: state.thinkBlockId });
555
+ state.thinkBlockOpen = false;
556
+ }
557
+ }
558
+
559
+ function resetTurnState() {
560
+ state.textBlockOpen = false;
561
+ state.textBlockId = null;
562
+ state.thinkBlockOpen = false;
563
+ state.thinkBlockId = null;
564
+ state.toolBlocks = {};
565
+ state.toolMeta = {};
566
+ state.toolContent = {};
567
+ }
568
+
569
+ function emitResult() {
570
+ var inputTokens = state.lastInputTokens || 0;
571
+ var hasTokenData = inputTokens > 0;
572
+ var resultModelUsage = {};
573
+ resultModelUsage[state.model] = { contextWindow: state.contextWindow || null };
574
+ pushEvent({
575
+ yokeType: "result",
576
+ uuid: generateUuid(),
577
+ messageType: "assistant",
578
+ cost: null,
579
+ duration: null,
580
+ usage: hasTokenData ? {
581
+ input_tokens: inputTokens,
582
+ output_tokens: 0,
583
+ cache_read_input_tokens: 0,
584
+ cache_creation_input_tokens: 0,
585
+ } : null,
586
+ modelUsage: resultModelUsage,
587
+ sessionId: state.sessionId || null,
588
+ lastStreamInputTokens: state.lastInputTokens || null,
589
+ });
590
+ }
591
+
592
+ function setSessionModel(model) {
593
+ if (!state.sessionId || !acp.started) return Promise.resolve();
594
+ if (state.engine === "v3") {
595
+ return acp.send("session/set_config_option", {
596
+ sessionId: state.sessionId,
597
+ configId: "model",
598
+ value: model,
599
+ }, 15000);
600
+ }
601
+ return acp.send("session/set_model", { sessionId: state.sessionId, modelId: model }, 15000);
602
+ }
603
+
604
+ // --- Main query loop ---
605
+ async function runQueryLoop(initialMessage) {
606
+ // Prepend the YOKE-merged system prompt to the first message's text.
607
+ var currentMessage;
608
+ if (!systemPrompt) {
609
+ currentMessage = initialMessage;
610
+ } else if (typeof initialMessage === "string") {
611
+ currentMessage = systemPrompt + "\n\n" + initialMessage;
612
+ } else if (Array.isArray(initialMessage)) {
613
+ var cloned = initialMessage.slice();
614
+ var injected = false;
615
+ for (var i = 0; i < cloned.length; i++) {
616
+ if (cloned[i] && cloned[i].type === "text") {
617
+ cloned[i] = { type: "text", text: systemPrompt + "\n\n" + (cloned[i].text || "") };
618
+ injected = true;
619
+ break;
620
+ }
621
+ }
622
+ if (!injected) cloned.unshift({ type: "text", text: systemPrompt });
623
+ currentMessage = cloned;
624
+ } else {
625
+ currentMessage = initialMessage;
626
+ }
627
+
628
+ // Registered on the shared ACP process for the lifetime of this query, and
629
+ // removed in the finally below. Events are routed to it by sessionId.
630
+ var handlerEntry = null;
631
+
632
+ try {
633
+ handlerEntry = acp.addHandler(handleServerEvent);
634
+
635
+ // Create or resume the session.
636
+ if (state.sessionId) {
637
+ // Bind before sending so replayed events from session/load route here.
638
+ handlerEntry.sessionId = state.sessionId;
639
+ state.loadingSession = true;
640
+ try {
641
+ await acp.send("session/load", {
642
+ sessionId: state.sessionId,
643
+ cwd: queryOpts.cwd,
644
+ mcpServers: queryOpts.mcpServers || [],
645
+ }, 60000);
646
+ } catch (e) {
647
+ // If load fails (unknown session), fall back to a fresh session.
648
+ console.warn("[yoke/kiro] session/load failed, starting fresh:", e.message);
649
+ state.sessionId = null;
650
+ handlerEntry.sessionId = null;
651
+ } finally {
652
+ state.loadingSession = false;
653
+ }
654
+ }
655
+ if (!state.sessionId) {
656
+ var newResult = await acp.send("session/new", {
657
+ cwd: queryOpts.cwd,
658
+ mcpServers: queryOpts.mcpServers || [],
659
+ }, 60000);
660
+ state.sessionId = newResult && newResult.sessionId;
661
+ }
662
+ handlerEntry.sessionId = state.sessionId || null;
663
+
664
+ // Kiro v3 defaults to autopilot, which bypasses permission requests.
665
+ // Supervised mode is mandatory so every tool call reaches canUseTool.
666
+ if (state.engine === "v3") {
667
+ await acp.send("session/set_config_option", {
668
+ sessionId: state.sessionId,
669
+ configId: "autopilot",
670
+ value: "off",
671
+ }, 15000);
672
+ }
673
+
674
+ // Select model + mode for the session (best-effort; failures are non-fatal).
675
+ if (queryOpts.model) {
676
+ await setSessionModel(queryOpts.model).catch(function() {});
677
+ }
678
+ if (queryOpts.mode) {
679
+ await acp.send("session/set_mode", { sessionId: state.sessionId, modeId: queryOpts.mode }, 15000).catch(function() {});
680
+ }
681
+
682
+ while (!isCancelled()) {
683
+ resetTurnState();
684
+
685
+ var input = typeof currentMessage === "string"
686
+ ? [{ type: "text", text: currentMessage }]
687
+ : currentMessage;
688
+
689
+ // Emit turn_start so the UI records a user turn boundary.
690
+ pushEvent({ yokeType: "turn_start", uuid: generateUuid(), messageType: "user" });
691
+
692
+ var promptResult = await acp.send("session/prompt", {
693
+ sessionId: state.sessionId,
694
+ prompt: input,
695
+ }, 30 * 60 * 1000);
696
+
697
+ closeOpenBlocks();
698
+
699
+ var stopReason = promptResult && promptResult.stopReason;
700
+ if (isCancelled() || stopReason === "cancelled") {
701
+ pushEvent({ yokeType: "interrupted" });
702
+ emitResult();
703
+ break;
704
+ }
705
+ emitResult();
706
+
707
+ var nextMsg = await waitForMessage();
708
+ if (nextMsg === null) break;
709
+ currentMessage = nextMsg;
710
+ }
711
+ } catch (e) {
712
+ if (!isCancelled() && e.name !== "AbortError") {
713
+ var loopErrMsg = e.message || String(e);
714
+ console.error("[yoke/kiro] runQueryLoop error:", loopErrMsg);
715
+ pushEvent(isKiroAuthError(loopErrMsg, e.rpcError)
716
+ ? { yokeType: "auth_required", vendor: "kiro" }
717
+ : { yokeType: "error", text: loopErrMsg });
718
+ }
719
+ } finally {
720
+ // Leaving this registered would keep routing events (and permission
721
+ // requests) to a dead query.
722
+ if (handlerEntry) acp.removeHandler(handlerEntry);
723
+ }
724
+
725
+ state.done = true;
726
+ endIterator();
727
+ }
728
+
729
+ var handle = {
730
+ [Symbol.asyncIterator]: function() {
731
+ return {
732
+ next: function() {
733
+ if (eventBuffer.length > 0) return Promise.resolve({ value: eventBuffer.shift(), done: false });
734
+ if (iteratorDone) return Promise.resolve({ value: undefined, done: true });
735
+ return new Promise(function(resolve) { eventWaiting = resolve; });
736
+ },
737
+ };
738
+ },
739
+
740
+ pushMessage: function(text, images) {
741
+ var input = [];
742
+ if (images && images.length > 0) {
743
+ for (var i = 0; i < images.length; i++) {
744
+ var img = images[i];
745
+ if (img && img.base64 && img.mimeType) {
746
+ input.push({ type: "image", data: img.base64, mimeType: img.mimeType });
747
+ }
748
+ }
749
+ }
750
+ input.push({ type: "text", text: text || "" });
751
+ var payload = input.length === 1 && input[0].type === "text" ? input[0].text : input;
752
+
753
+ if (!state.loopStarted) {
754
+ state.loopStarted = true;
755
+ runQueryLoop(payload);
756
+ } else {
757
+ pushMessageToQueue(payload);
758
+ }
759
+ },
760
+
761
+ setModel: function(model) {
762
+ state.model = model;
763
+ return setSessionModel(model).catch(function() {});
764
+ },
765
+
766
+ setEffort: function() { return Promise.resolve(); },
767
+ setToolPolicy: function() { return Promise.resolve(); },
768
+ stopTask: function() { return Promise.resolve(); },
769
+
770
+ getContextUsage: function() {
771
+ return Promise.resolve(state.lastInputTokens != null ? {
772
+ input_tokens: state.lastInputTokens,
773
+ contextWindow: state.contextWindow || null,
774
+ } : null);
775
+ },
776
+
777
+ abort: function() {
778
+ console.log("[yoke/kiro] handle.abort() sessionId=" + state.sessionId + " already=" + state.aborted);
779
+ state.aborted = true;
780
+ if (state.sessionId && acp.started) {
781
+ // ACP cancellation is a notification; the in-flight session/prompt will
782
+ // resolve with stopReason "cancelled".
783
+ acp.notify("session/cancel", { sessionId: state.sessionId });
784
+ }
785
+ endIterator();
786
+ },
787
+
788
+ close: function() {
789
+ messageQueueEnded = true;
790
+ if (messageWaiting) {
791
+ var resolve = messageWaiting;
792
+ messageWaiting = null;
793
+ resolve(null);
794
+ }
795
+ endIterator();
796
+ },
797
+
798
+ endInput: function() {
799
+ messageQueueEnded = true;
800
+ if (messageWaiting) {
801
+ var resolve = messageWaiting;
802
+ messageWaiting = null;
803
+ resolve(null);
804
+ }
805
+ },
806
+ };
807
+
808
+ if (abortController && abortController.signal) {
809
+ abortController.signal.addEventListener("abort", function() {
810
+ if (!state.aborted) handle.abort();
811
+ }, { once: true });
812
+ }
813
+
814
+ return handle;
815
+ }
816
+
817
+ // --- Adapter factory ---
818
+
819
+ function createKiroAdapter(opts) {
820
+ var _cwd = (opts && opts.cwd) || process.cwd();
821
+ var _slug = (opts && opts.slug) || "";
822
+ var _defaultInitOpts = Object.assign({}, opts || {});
823
+ var _AcpServerCtor = (opts && opts._AcpServerCtor) || KiroAcpServer;
824
+ var _fetchModels = (opts && opts._fetchModels) || fetchModelsViaCli;
825
+ var _fetchKasToken = (opts && opts._fetchKasToken) || fetchKasTokenViaCli;
826
+ var _engine = (opts && opts.engine) || process.env.CLAY_KIRO_AGENT_ENGINE || KIRO_DEFAULTS.engine;
827
+ var _binaryPath = (opts && opts._binaryPath) || null;
828
+ if (!_binaryPath) {
829
+ try { _binaryPath = findKiroPath(); } catch (e) { _binaryPath = null; }
830
+ }
831
+
832
+ var _acp = null;
833
+ var _initPromise = null;
834
+ var _initialized = false;
835
+ var _shutdownPromise = null;
836
+ var _shuttingDown = false;
837
+ var _refCount = 0;
838
+ var _lastActiveAt = Date.now();
839
+ var _activeQueries = [];
840
+ var _cachedModels = [];
841
+ var _modelContextWindows = {};
842
+ var _defaultModel = "auto";
843
+
844
+ function updateLastActiveAt() { _lastActiveAt = Date.now(); }
845
+ function registerActiveQuery(entry) { _activeQueries.push(entry); }
846
+ function removeActiveQuery(entry) {
847
+ var next = [];
848
+ for (var i = 0; i < _activeQueries.length; i++) {
849
+ if (_activeQueries[i] !== entry) next.push(_activeQueries[i]);
850
+ }
851
+ _activeQueries = next;
852
+ }
853
+ function decrementRefCount() {
854
+ if (_refCount > 0) _refCount--;
855
+ else { console.error("[yoke/kiro] refCount negative, bug!"); _refCount = 0; }
856
+ updateLastActiveAt();
857
+ }
858
+
859
+ function buildReadyResponse(skillNames) {
860
+ return {
861
+ models: _cachedModels,
862
+ defaultModel: _defaultModel,
863
+ skills: skillNames || [],
864
+ slashCommands: skillNames || [],
865
+ fastModeState: null,
866
+ capabilities: {
867
+ thinking: true,
868
+ betas: false,
869
+ rewind: false,
870
+ sessionResume: true,
871
+ promptSuggestions: false,
872
+ elicitation: false,
873
+ fileCheckpointing: false,
874
+ contextCompacting: true,
875
+ toolPolicy: ["ask", "allow-all"],
876
+ },
877
+ };
878
+ }
879
+
880
+ function clearRuntimeState() {
881
+ _acp = null;
882
+ _initPromise = null;
883
+ _initialized = false;
884
+ _refCount = 0;
885
+ _activeQueries = [];
886
+ updateLastActiveAt();
887
+ }
888
+
889
+ function waitForRefCount(targetCount, timeoutMs) {
890
+ var deadline = Date.now() + (timeoutMs || 5000);
891
+ return new Promise(function(resolve) {
892
+ function tick() {
893
+ if (_refCount <= targetCount) { resolve(true); return; }
894
+ if (Date.now() >= deadline) { resolve(false); return; }
895
+ setTimeout(tick, 50);
896
+ }
897
+ tick();
898
+ });
899
+ }
900
+
901
+ function stopAcp(deadlineMs, acpInstance) {
902
+ var target = acpInstance || _acp;
903
+ var proc = target && target.proc ? target.proc : null;
904
+ if (!target) return Promise.resolve(true);
905
+ try { target.stop(); } catch (e) { console.error("[yoke/kiro] ACP stop error:", e.message || e); }
906
+ if (!proc) return Promise.resolve(true);
907
+ var remaining = (typeof deadlineMs === "number") ? Math.max(0, deadlineMs - Date.now()) : 5000;
908
+ return waitForProcessExit(proc, remaining).then(function(exited) {
909
+ if (!exited) { try { proc.kill("SIGKILL"); } catch (e) {} }
910
+ return exited;
911
+ });
912
+ }
913
+
914
+ function beginShutdown(force) {
915
+ if (_shutdownPromise) return _shutdownPromise;
916
+ if (_shuttingDown) return null;
917
+ _shuttingDown = true;
918
+
919
+ _shutdownPromise = (async function() {
920
+ var deadline = Date.now() + 5000;
921
+ if (_initPromise) {
922
+ try { await Promise.race([_initPromise.catch(function() { return null; }), waitMs(Math.max(0, deadline - Date.now()))]); } catch (e) {}
923
+ }
924
+ if (force && _activeQueries.length > 0) {
925
+ var active = _activeQueries.slice();
926
+ for (var i = 0; i < active.length; i++) {
927
+ try { if (active[i] && active[i].abort) active[i].abort(); } catch (e) {}
928
+ }
929
+ await waitForRefCount(0, Math.max(0, deadline - Date.now()));
930
+ }
931
+ if (_acp) await stopAcp(deadline);
932
+ clearRuntimeState();
933
+ _shuttingDown = false;
934
+ _shutdownPromise = null;
935
+ return true;
936
+ })().catch(function(err) {
937
+ clearRuntimeState();
938
+ _shuttingDown = false;
939
+ _shutdownPromise = null;
940
+ throw err;
941
+ });
942
+
943
+ return _shutdownPromise;
944
+ }
945
+
946
+ var adapter = {
947
+ vendor: "kiro",
948
+
949
+ init: function(initOpts) {
950
+ if (_shuttingDown) return Promise.reject(createShutdownError());
951
+ var effectiveInitOpts = Object.assign({}, _defaultInitOpts, initOpts || {});
952
+
953
+ if (_initialized && _acp && _acp.started && _cachedModels.length > 0) {
954
+ return Promise.resolve(buildReadyResponse([]));
955
+ }
956
+ if (_initPromise) return _initPromise;
957
+
958
+ var attemptAcp = null;
959
+ var attemptPromise;
960
+ attemptPromise = (async function() {
961
+ if (!_binaryPath) {
962
+ try { _binaryPath = findKiroPath(); }
963
+ catch (e) { throw new Error("kiro-cli binary not found: " + e.message); }
964
+ }
965
+
966
+ // Fetch the model catalog (dynamic, like Claude). Non-fatal on failure.
967
+ var catalog = await _fetchModels(_binaryPath, _cwd);
968
+ if (catalog && catalog.models.length > 0) {
969
+ _cachedModels = catalog.models;
970
+ _defaultModel = catalog.defaultModel || "auto";
971
+ _modelContextWindows = catalog.contextWindows || {};
972
+ }
973
+
974
+ // Spawn and initialize the ACP server.
975
+ attemptAcp = new _AcpServerCtor(_binaryPath, {
976
+ cwd: _cwd,
977
+ env: effectiveInitOpts.env || null,
978
+ extraArgs: _engine ? ["--agent-engine", _engine] : [],
979
+ });
980
+ _acp = attemptAcp;
981
+ _acp.addRequestHandler("_kiro/auth/getAccessToken", function() {
982
+ return _fetchKasToken(_binaryPath, _cwd);
983
+ });
984
+ await _acp.start();
985
+ await _acp.send("initialize", {
986
+ protocolVersion: 1,
987
+ clientInfo: { name: "clay", version: "1.0.0" },
988
+ // Do not advertise fs capabilities we have no handler for: kiro-cli
989
+ // would send fs/read_text_file and block waiting for a response.
990
+ // These are also direct client-side file operations rather than tool
991
+ // calls, so implementing them later means bypassing canUseTool and
992
+ // needs explicit cwd confinement first.
993
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
994
+ }, 30000);
995
+ _initialized = true;
996
+
997
+ if (_shuttingDown) throw createShutdownError();
998
+
999
+ // If the CLI catalog was unavailable, fall back to a minimal default set.
1000
+ if (_cachedModels.length === 0) {
1001
+ _cachedModels = ["auto"];
1002
+ _defaultModel = "auto";
1003
+ }
1004
+
1005
+ // Discover Claude skills so $<skill-name> references resolve.
1006
+ var skillNames = Object.keys(discoverClaudeSkills(_cwd));
1007
+ console.log("[yoke/kiro] ACP initialized, models: " + _cachedModels.length + ", skills: " + skillNames.length);
1008
+
1009
+ updateLastActiveAt();
1010
+ return buildReadyResponse(skillNames);
1011
+ })().then(function(result) {
1012
+ if (_initPromise === attemptPromise) _initPromise = null;
1013
+ return result;
1014
+ }, async function(err) {
1015
+ // A failed handshake must never leave a rejected memoized promise or a
1016
+ // live, uninitialized ACP process behind. Null the shared reference
1017
+ // before stopping so beginShutdown cannot stop the same child twice.
1018
+ if (_acp === attemptAcp) _acp = null;
1019
+ _initialized = false;
1020
+ if (attemptAcp) {
1021
+ try { await stopAcp(Date.now() + 5000, attemptAcp); } catch (stopErr) {}
1022
+ }
1023
+ if (_initPromise === attemptPromise) _initPromise = null;
1024
+ throw err;
1025
+ });
1026
+
1027
+ _initPromise = attemptPromise;
1028
+
1029
+ return _initPromise;
1030
+ },
1031
+
1032
+ supportedModels: function() {
1033
+ if (_cachedModels.length > 0) return Promise.resolve(_cachedModels.slice());
1034
+ if (!_binaryPath) return Promise.resolve([]);
1035
+ return fetchModelsViaCli(_binaryPath, _cwd).then(function(catalog) {
1036
+ if (catalog && catalog.models.length > 0) {
1037
+ _cachedModels = catalog.models;
1038
+ _defaultModel = catalog.defaultModel || "auto";
1039
+ _modelContextWindows = catalog.contextWindows || {};
1040
+ }
1041
+ return _cachedModels.slice();
1042
+ });
1043
+ },
1044
+
1045
+ createToolServer: function() {
1046
+ // Kiro handles tools internally; MCP goes through session/new mcpServers.
1047
+ return null;
1048
+ },
1049
+
1050
+ createQuery: async function(queryOpts) {
1051
+ if (_shuttingDown) throw createShutdownError();
1052
+ if (!_acp || !_acp.started) await adapter.init(queryOpts || {});
1053
+ if (_shuttingDown) throw createShutdownError();
1054
+ if (!_acp || !_acp.started) throw new Error("[yoke/kiro] Adapter not initialized. Call init() first.");
1055
+
1056
+ var model = queryOpts.model || _defaultModel || "auto";
1057
+ var ac = queryOpts.abortController || new AbortController();
1058
+ var kiroOpts = (queryOpts.adapterOptions && queryOpts.adapterOptions.KIRO) || {};
1059
+
1060
+ var activeEntry = { abort: function() { try { ac.abort(); } catch (e) {} } };
1061
+
1062
+ var handleOpts = {
1063
+ model: model,
1064
+ engine: _engine,
1065
+ contextWindow: _modelContextWindows[model] || null,
1066
+ mode: kiroOpts.mode || null,
1067
+ cwd: queryOpts.cwd || _cwd,
1068
+ systemPrompt: queryOpts.systemPrompt || "",
1069
+ abortController: ac,
1070
+ canUseTool: queryOpts.canUseTool || null,
1071
+ resumeSessionId: queryOpts.resumeSessionId || null,
1072
+ mcpServers: kiroOpts.mcpServers || [],
1073
+ };
1074
+
1075
+ console.log("[yoke/kiro] createQuery: model=" + model + " resume=" + (handleOpts.resumeSessionId || "none"));
1076
+
1077
+ _refCount++;
1078
+ registerActiveQuery(activeEntry);
1079
+
1080
+ var handle;
1081
+ try {
1082
+ handleOpts.onFinished = function() {
1083
+ removeActiveQuery(activeEntry);
1084
+ decrementRefCount();
1085
+ };
1086
+ handle = createKiroQueryHandle(_acp, handleOpts);
1087
+ } catch (e) {
1088
+ removeActiveQuery(activeEntry);
1089
+ decrementRefCount();
1090
+ throw e;
1091
+ }
1092
+
1093
+ activeEntry.handle = handle;
1094
+ activeEntry.abort = function() {
1095
+ try {
1096
+ if (handle && typeof handle.abort === "function") handle.abort();
1097
+ else ac.abort();
1098
+ } catch (e) {}
1099
+ };
1100
+
1101
+ return handle;
1102
+ },
1103
+
1104
+ generateTitle: async function(messages, opts) {
1105
+ var systemPrompt = "You are a title generator. Output only a short title (3-8 words). No quotes, no punctuation at the end, no explanation.";
1106
+ var prompt = "Below is a conversation between a user and an AI assistant. Generate a short, descriptive title (3-8 words) that captures the main topic. Reply with ONLY the title, nothing else.\n\n";
1107
+ for (var i = 0; i < messages.length; i++) {
1108
+ prompt += "User message " + (i + 1) + ": " + messages[i] + "\n";
1109
+ }
1110
+ var ac = new AbortController();
1111
+ var handle = await adapter.createQuery({
1112
+ cwd: (opts && opts.cwd) || _cwd,
1113
+ systemPrompt: systemPrompt,
1114
+ model: "auto",
1115
+ abortController: ac,
1116
+ canUseTool: function() { return Promise.resolve({ behavior: "deny", message: "No tools." }); },
1117
+ });
1118
+ handle.pushMessage(prompt);
1119
+ var title = "";
1120
+ try {
1121
+ for await (var msg of handle) {
1122
+ if (msg.yokeType === "text_delta" && msg.text) title += msg.text;
1123
+ else if (msg.yokeType === "result") break;
1124
+ }
1125
+ } finally {
1126
+ handle.close();
1127
+ }
1128
+ return title.replace(/[\r\n]+/g, " ").replace(/^["'\s]+|["'\s.]+$/g, "").trim();
1129
+ },
1130
+
1131
+ getSessionInfo: function() { return Promise.resolve(null); },
1132
+ listSessions: function() { return Promise.resolve([]); },
1133
+ renameSession: function() { return Promise.resolve(); },
1134
+ forkSession: function() { return Promise.resolve(null); },
1135
+
1136
+ shutdown: function() { return beginShutdown(true); },
1137
+
1138
+ shutdownIfIdle: function(idleMs) {
1139
+ if (_shuttingDown || _shutdownPromise) return Promise.resolve(false);
1140
+ if (_initPromise) return Promise.resolve(false);
1141
+ if (!_acp) return Promise.resolve(false);
1142
+ if (_refCount > 0) return Promise.resolve(false);
1143
+ if (Date.now() - _lastActiveAt < (idleMs || 0)) return Promise.resolve(false);
1144
+ return beginShutdown(false).then(function() {
1145
+ console.log("[yoke/kiro] Reclaimed idle adapter for project " + (_slug || _cwd));
1146
+ return true;
1147
+ });
1148
+ },
1149
+ };
1150
+
1151
+ return adapter;
1152
+ }
1153
+
1154
+ module.exports = {
1155
+ createKiroAdapter: createKiroAdapter,
1156
+ };