Skip to content
Source
Sign inSign up

adapter.ts

653 lines25.7 KB

1import * as HostProcess from "@t3tools/shared/HostProcess";
2import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime";
3import {
4 type ModelSelection,
5 ProviderDriverKind,
6 type OrchestrationV2ProviderCapabilities,
7} from "@t3tools/contracts";
8import { KiroSettings } from "../settings.ts";
9import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
10import type * as SecureRandom from "effect/crypto/SecureRandom";
11import * as Effect from "effect/Effect";
12import type * as FileSystem from "effect/FileSystem";
13import * as Option from "effect/Option";
14import type * as Path from "effect/Path";
15import * as Schema from "effect/Schema";
16import type * as Scope from "effect/Scope";
17import * as Stream from "effect/Stream";
18import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
19import type * as EffectAcpSchema from "effect-acp/compat";
20import * as EffectAcpErrors from "effect-acp/errors";
21​
22import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
23import {
24 AcpProviderCapabilitiesV2,
25 makeAcpAdapterV2,
26 type AcpAdapterV2Flavor,
27 type AcpAdapterV2RuntimeInput,
28 type AcpAdapterV2SubagentUpdate,
29} from "@t3tools/provider-acp/server/adapter";
30import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
31import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging";
32import {
33 ProviderAdapterDriverCreateError,
34 type ProviderAdapterDriver,
35 type ProviderAdapterDriverCreateInput,
36} from "@t3tools/provider-core/server/adapterDriver";
37import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
38import type * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
39import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
40import type * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
41import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
42import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
43import {
44 KIRO_AGENT_CONFIG_ID,
45 KIRO_AUTOPILOT_CONFIG_ID,
46 KIRO_EFFORT_CONFIG_ID,
47 KIRO_MODEL_CONFIG_ID,
48 kiroApprovalOptions,
49 kiroAutopilotValue,
50 kiroPermissionDisposition,
51 makeKiroAcpRuntime,
52} from "./acpSupport.ts";
53​
54export const KIRO_PROVIDER = ProviderDriverKind.make("kiro");
55/** T3's slug for "Kiro default", and Kiro's documented default model id. */
56const KIRO_DEFAULT_MODEL = "default";
57const KIRO_COMPACT_METHOD = "_kiro/session/compact";
58const KIRO_STEER_METHOD = "_session/steer";
59const KIRO_FALLBACK_DEFAULT_MODEL = "auto";
60const DEFAULT_KIRO_SETTINGS = Schema.decodeSync(KiroSettings)({});
61​
62const KiroProviderCapabilitiesV2 = {
63 ...AcpProviderCapabilitiesV2,
64 sessions: {
65 ...AcpProviderCapabilitiesV2.sessions,
66 supportsModelSwitchInSession: true,
67 },
68 // Kiro 2.27 and later fork at a turn's prompt (`_meta.kiro.messageId`); see kiroForkFromTurn.
69 threads: {
70 ...AcpProviderCapabilitiesV2.threads,
71 canForkThread: true,
72 canForkFromTurn: true,
73 },
74 // `_session/steer` waits for the running tool, then Kiro reads the message at its next step.
75 turns: {
76 ...AcpProviderCapabilitiesV2.turns,
77 supportsActiveSteering: true,
78 activeSteeringInterruptsTools: false,
79 // `_session/steer` takes a string, so a message with images restarts the turn.
80 activeSteeringAcceptsAttachments: false,
81 },
82 tools: {
83 ...AcpProviderCapabilitiesV2.tools,
84 supportsMcpTools: true,
85 },
86 subagents: {
87 ...AcpProviderCapabilitiesV2.subagents,
88 supportsSubagents: true,
89 emitsSubagentLifecycle: true,
90 },
91} satisfies OrchestrationV2ProviderCapabilities;
92​
93const KiroCompactResult = Schema.Struct({ success: Schema.Boolean });
94const decodeKiroCompactResult = Schema.decodeUnknownOption(KiroCompactResult);
95​
96const decodeKiroExtensionMethods = Schema.decodeUnknownOption(
97 Schema.Struct({ kiro: Schema.Struct({ extensionMethods: Schema.Array(Schema.String) }) }),
98);
99​
100/**
101 * Kiro answers a `/compact` prompt as chat, so T3 compacts with Kiro's own request,
102 * on deployments whose `initialize` result lists it among Kiro's extension methods.
103 */
104const kiroCompactSession = {
105 supported: (
106 initializeResult: AcpSessionRuntime.AcpSessionRuntimeStartResult["initializeResult"],
107 ) =>
108 Option.exists(decodeKiroExtensionMethods(initializeResult.agentCapabilities?._meta), (meta) =>
109 meta.kiro.extensionMethods.includes(KIRO_COMPACT_METHOD),
110 ),
111 compact: (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], sessionId: string) =>
112 runtime.request(KIRO_COMPACT_METHOD, { sessionId }).pipe(
113 Effect.flatMap((result) =>
114 Option.exists(decodeKiroCompactResult(result), (decoded) => decoded.success)
115 ? Effect.void
116 : Effect.fail(
117 new EffectAcpErrors.AcpRequestError({
118 code: -32603,
119 errorMessage: "Kiro could not compact the conversation.",
120 method: KIRO_COMPACT_METHOD,
121 data: result,
122 }),
123 ),
124 ),
125 ),
126};
127​
128const KiroSteerResult = Schema.Struct({ queued: Schema.Literal(true), messageId: Schema.String });
129const decodeKiroSteerResult = Schema.decodeUnknownOption(KiroSteerResult);
130const KiroSteeringInjected = Schema.Struct({
131 kiro: Schema.Struct({ kind: Schema.Literal("steering_injected"), messageId: Schema.String }),
132});
133const decodeKiroSteeringInjected = Schema.decodeUnknownOption(KiroSteeringInjected);
134​
135/** Kiro queues a steer, then reports `steering_injected` with its id once the model reads it. */
136const kiroSteering = {
137 send: (
138 runtime: AcpSessionRuntime.AcpSessionRuntime["Service"],
139 sessionId: string,
140 text: string,
141 ) =>
142 runtime.request(KIRO_STEER_METHOD, { sessionId, message: text }).pipe(
143 Effect.flatMap((result) =>
144 Option.match(decodeKiroSteerResult(result), {
145 onSome: (decoded) => Effect.succeed(decoded.messageId),
146 onNone: () =>
147 Effect.fail(
148 new EffectAcpErrors.AcpRequestError({
149 code: -32603,
150 errorMessage: "Kiro did not queue the steering message.",
151 method: KIRO_STEER_METHOD,
152 data: result,
153 }),
154 ),
155 }),
156 ),
157 ),
158 injectedSteerId: (notification: EffectAcpSchema.SessionNotification) =>
159 notification.update.sessionUpdate === "session_info_update"
160 ? Option.getOrUndefined(decodeKiroSteeringInjected(notification.update._meta))?.kiro.messageId
161 : undefined,
162};
163​
164const decodeKiroForkCapability = Schema.decodeUnknownOption(
165 Schema.Struct({ kiro: Schema.Struct({ messageId: Schema.Literal(true) }) }),
166);
167const decodeKiroUserMessageId = Schema.decodeUnknownOption(
168 Schema.Struct({
169 kiro: Schema.Struct({
170 kind: Schema.Literal("user_message_id_assigned"),
171 userMessageId: Schema.String,
172 }),
173 }),
174);
175​
176/**
177 * Kiro assigns each prompt a `userMessageId`, and `session/fork` with that id keeps the
178 * session up to and including the reply to that prompt.
179 */
180const kiroForkFromTurn = {
181 supported: (
182 initializeResult: AcpSessionRuntime.AcpSessionRuntimeStartResult["initializeResult"],
183 ) =>
184 Option.isSome(
185 decodeKiroForkCapability(
186 initializeResult.agentCapabilities?.sessionCapabilities?.fork?._meta,
187 ),
188 ),
189 nativeTurnId: (notification: EffectAcpSchema.SessionNotification) =>
190 notification.update.sessionUpdate === "session_info_update"
191 ? Option.getOrUndefined(decodeKiroUserMessageId(notification.update._meta))?.kiro
192 .userMessageId
193 : undefined,
194 forkMeta: (messageId: string) => ({ kiro: { messageId } }),
195};
196​
197export interface KiroAdapterV2Options {
198 readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"];
199 readonly settings: KiroSettings;
200 readonly environment: NodeJS.ProcessEnv;
201 readonly selfInvocation: SelfInvocation;
202 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];
203 /**
204 * Kiro's id for "Kiro default" (the provider snapshot's `--list-models`
205 * default). Absent, it is `auto`.
206 */
207 readonly defaultModel?: Effect.Effect<string>;
208 /** Replaces the `kiro-cli` launch (replay tests). Kiro's session setup still applies. */
209 readonly makeRuntime?: (
210 input: AcpAdapterV2RuntimeInput,
211 ) => Effect.Effect<
212 AcpSessionRuntime.AcpSessionRuntime["Service"],
213 EffectAcpErrors.AcpError,
214 ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | Scope.Scope
215 >;
216 readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
217 readonly testHooks?: Parameters<typeof makeAcpAdapterV2>[0]["testHooks"];
219​
220/** Kiro's `model` option once it advertises one. */
221const findKiroModelOption = (configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>) =>
222 configOptions.find((option) => option.id === KIRO_MODEL_CONFIG_ID || option.category === "model");
223​
224/**
225 * Kiro selects models only through its `model` config option; "default" is
226 * Kiro's own default. The write goes to `model` even before Kiro advertises it,
227 * and for unlisted ids: Kiro fails the next prompt on a model it can't use.
228 */
229const applyKiroModel = Effect.fn("KiroAdapterV2.applyKiroModel")(function* (
230 runtime: AcpSessionRuntime.AcpSessionRuntime["Service"],
231 modelSelection: ModelSelection,
232 defaultModel: Effect.Effect<string>,
233 agentSwitched: boolean,
234) {
235 const modelOption = findKiroModelOption(yield* runtime.getConfigOptions);
236 const current = modelOption?.type === "select" ? modelOption.currentValue : undefined;
237 const selected = modelSelection.model.trim();
238 const isDefault = selected.length === 0 || selected === KIRO_DEFAULT_MODEL;
239 // Before Kiro advertises `model` the session is new, so it already runs
240 // Kiro's default, unless an agent switch just moved it to the agent's model.
241 if (isDefault && modelOption?.type !== "select" && !agentSwitched) {
242 return current;
243 }
244 const requested = isDefault ? yield* defaultModel : selected;
245 if (requested === current) {
246 return current;
247 }
248 yield* runtime.setConfigOption(modelOption?.id ?? KIRO_MODEL_CONFIG_ID, requested, {
249 allowUnlistedValue: true,
250 });
251 return requested;
252});
253​
254/**
255 * Bound on waiting for Kiro to advertise the model T3 just wrote. Kiro 2.27.0
256 * sent it 40-90 ms after the write's result.
257 */
258const KIRO_MODEL_ADVERT_BOUND = "2 seconds";
259​
260/**
261 * Writes the thread's effort to Kiro's `effortLevel` once Kiro advertises the
262 * written model, since Kiro ignores the option before then and resets it on a
263 * model change. Levels the model doesn't offer are not sent. A rejected write
264 * or no advert within the bound keeps Kiro's level rather than failing the turn.
265 */
266const applyKiroEffort = Effect.fn("KiroAdapterV2.applyKiroEffort")(function* (
267 runtime: AcpSessionRuntime.AcpSessionRuntime["Service"],
268 modelSelection: ModelSelection,
269 appliedModel: string | undefined,
270) {
271 const requested = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort");
272 if (requested === undefined) return;
273 const advertised = yield* runtime.configOptionChanges.pipe(
274 Stream.filter((configOptions) => {
275 const modelOption = findKiroModelOption(configOptions);
276 return (
277 appliedModel === undefined ||
278 (modelOption?.type === "select" && modelOption.currentValue === appliedModel)
279 );
280 }),
281 Stream.runHead,
282 Effect.timeoutOption(KIRO_MODEL_ADVERT_BOUND),
283 Effect.map(Option.flatten),
284 );
285 if (Option.isNone(advertised)) {
286 return yield* Effect.logWarning("Kiro did not advertise the model's reasoning effort", {
287 waitedForWrittenModel: appliedModel !== undefined,
288 });
289 }
290 const effortOption = advertised.value.find((option) => option.id === KIRO_EFFORT_CONFIG_ID);
291 if (effortOption?.type !== "select" || !selectValues(effortOption).includes(requested)) {
292 return;
293 }
294 yield* runtime.setConfigOption(KIRO_EFFORT_CONFIG_ID, requested).pipe(
295 Effect.catchTags({
296 // `requested` is one of the levels Kiro just advertised, so it is safe to log.
297 AcpRequestError: (error) =>
298 Effect.logWarning("Kiro rejected the reasoning effort", {
299 value: requested,
300 code: error.code,
301 }),
302 }),
303 );
304});
305​
306/**
307 * Selects the thread's Kiro agent and reports whether a switch went out. It runs
308 * before the model write, because switching applies the agent's own model. An
309 * agent the session doesn't list is skipped: Kiro would accept it and not switch.
310 */
311const applyKiroAgent = Effect.fn("KiroAdapterV2.applyKiroAgent")(function* (
312 runtime: AcpSessionRuntime.AcpSessionRuntime["Service"],
313 modelSelection: ModelSelection,
314) {
315 const requested = getModelSelectionStringOptionValue(modelSelection, KIRO_AGENT_CONFIG_ID);
316 if (requested === undefined) return false;
317 const agentOption = (yield* runtime.getConfigOptions).find(
318 (option) => option.id === KIRO_AGENT_CONFIG_ID,
319 );
320 if (agentOption?.type !== "select" || agentOption.currentValue === requested) return false;
321 if (!selectValues(agentOption).includes(requested)) {
322 yield* Effect.logWarning("Kiro does not list the thread's agent", {
323 listedAgents: selectValues(agentOption).length,
324 });
325 return false;
326 }
327 yield* runtime.setConfigOption(KIRO_AGENT_CONFIG_ID, requested);
328 return true;
329});
330​
331const applyKiroModelSelection =
332 (defaultModel: Effect.Effect<string>): NonNullable<AcpAdapterV2Flavor["applyModelSelection"]> =>
333 ({ runtime, modelSelection }) =>
334 applyKiroAgent(runtime, modelSelection).pipe(
335 Effect.flatMap((agentSwitched) =>
336 applyKiroModel(runtime, modelSelection, defaultModel, agentSwitched),
337 ),
338 Effect.tap((appliedModel) => applyKiroEffort(runtime, modelSelection, appliedModel)),
339 );
340​
341function selectValues(
342 option: Extract<EffectAcpSchema.SessionConfigOption, { readonly type: "select" }>,
343): ReadonlyArray<string> {
344 return option.options.flatMap((entry) =>
345 "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value),
346 );
348​
349const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError);
350​
351/**
352 * Evidence that Kiro started the current prompt, so Stop's `session/cancel` is
353 * honored: a `turn_start` info update or output only a running prompt produces.
354 * Kiro doesn't promise `turn_start`, so Stop gives up after KIRO_CANCEL_HOLD_BOUND.
355 */
356function isKiroPromptActivity(update: EffectAcpSchema.SessionUpdate): boolean {
357 switch (update.sessionUpdate) {
358 case "agent_message_chunk":
359 case "agent_message":
360 case "agent_thought_chunk":
361 case "agent_thought":
362 case "tool_call":
363 case "tool_call_update":
364 case "tool_call_content_chunk":
365 case "plan_update":
366 return true;
367 case "session_info_update": {
368 const kiro = update._meta?.kiro;
369 return (
370 typeof kiro === "object" && kiro !== null && "kind" in kiro && kiro.kind === "turn_start"
371 );
372 }
373 default:
374 return false;
375 }
377​
378/** Past the 0-0.5 s window where TinBane saw cancels dropped, and the 1.5 s cancel they saw honored. */
379const KIRO_CANCEL_HOLD_BOUND = "2 seconds";
380​
381/**
382 * Kiro 2.31 fetches an Identity Center account's cloud config as a tool call
383 * of its own around session start (reported by @hstern), which can land inside
384 * the user's turn. Only the call carries this id; its updates name the call.
385 */
386const KIRO_CLOUD_CONFIG_TOOL_ID = "fetch_cloud_config";
387​
388/** Drops Kiro's cloud-config tool call and every update that follows it. */
389function makeKiroCloudConfigFilter() {
390 const running = new Set<string>();
391 return (notification: EffectAcpSchema.SessionNotification): boolean => {
392 const { update } = notification;
393 if (
394 update.sessionUpdate !== "tool_call" &&
395 update.sessionUpdate !== "tool_call_update" &&
396 update.sessionUpdate !== "tool_call_content_chunk"
397 ) {
398 return false;
399 }
400 const key = `${notification.sessionId}\n${update.toolCallId}`;
401 const kiro = update._meta?.kiro;
402 const toolId =
403 typeof kiro === "object" && kiro !== null && "toolId" in kiro ? kiro.toolId : null;
404 if (toolId === KIRO_CLOUD_CONFIG_TOOL_ID) running.add(key);
405 else if (!running.has(key)) return false;
406 if ("status" in update && (update.status === "completed" || update.status === "failed")) {
407 running.delete(key);
408 }
409 return true;
410 };
412​
413const KIRO_STEERING_NOTE = "[STEERING steer-";
414const decodeKiroSteeringInfo = Schema.decodeUnknownOption(
415 Schema.Struct({ kiro: Schema.Struct({ kind: Schema.String }) }),
416);
417​
418/** Length of the longest end of `text` that could still grow into the steering note. */
419function steeringNotePrefixLength(text: string): number {
420 for (let length = Math.min(text.length, KIRO_STEERING_NOTE.length - 1); length > 0; length -= 1) {
421 if (KIRO_STEERING_NOTE.startsWith(text.slice(-length))) return length;
422 }
423 return 0;
425​
426/**
427 * After an injected steer, Kiro's model ends its reply with a `[STEERING steer-…: …]`
428 * note meant for Kiro's own UI. This drops it from the streamed text, holding back a
429 * chunk end that may be the start of the note until the next chunk shows otherwise.
430 */
431function makeKiroSteeringNoteFilter() {
432 const sessions = new Map<string, { held: string; inNote: boolean }>();
433 return (
434 notification: EffectAcpSchema.SessionNotification,
435 ): EffectAcpSchema.SessionNotification => {
436 const { update } = notification;
437 if (update.sessionUpdate === "session_info_update") {
438 const kind = Option.getOrUndefined(decodeKiroSteeringInfo(update._meta))?.kiro.kind;
439 if (kind === "steering_injected") {
440 sessions.set(notification.sessionId, { held: "", inNote: false });
441 } else if (kind === "steering_cleared") sessions.delete(notification.sessionId);
442 return notification;
443 }
444 const state = sessions.get(notification.sessionId);
445 if (
446 state === undefined ||
447 update.sessionUpdate !== "agent_message_chunk" ||
448 update.content.type !== "text"
449 ) {
450 return notification;
451 }
452 let pending = state.held + update.content.text;
453 let visible = "";
454 state.held = "";
455 while (pending.length > 0) {
456 if (state.inNote) {
457 const end = pending.indexOf("]");
458 if (end < 0) break;
459 pending = pending.slice(end + 1);
460 state.inNote = false;
461 continue;
462 }
463 const start = pending.indexOf(KIRO_STEERING_NOTE);
464 if (start >= 0) {
465 visible += pending.slice(0, start).trimEnd();
466 pending = pending.slice(start + KIRO_STEERING_NOTE.length);
467 state.inNote = true;
468 continue;
469 }
470 const held = steeringNotePrefixLength(pending);
471 visible += pending.slice(0, pending.length - held);
472 state.held = pending.slice(pending.length - held);
473 break;
474 }
475 return {
476 ...notification,
477 update: { ...update, content: { ...update.content, text: visible } },
478 };
479 };
481​
482const decodeKiroAgentSubtask = Schema.decodeUnknownOption(
483 Schema.Struct({
484 kiro: Schema.Struct({ agentSubtaskId: Schema.String, kind: Schema.optionalKey(Schema.String) }),
485 }),
486);
487const decodeKiroSubagentInput = Schema.decodeUnknownOption(
488 Schema.Struct({ name: Schema.String, prompt: Schema.String }),
489);
490​
491/**
492 * Kiro runs sub-agents inside the parent's session: frames from an
493 * `invoke_sub_agent` call carry `_meta.kiro.agentSubtaskId`, and routing them
494 * to that id as their session gives the shared adapter the child to project.
495 */
496function normalizeKiroSessionUpdate(
497 notification: EffectAcpSchema.SessionNotification,
498): EffectAcpSchema.SessionNotification {
499 const { update } = notification;
500 const subtask = decodeKiroAgentSubtask("_meta" in update ? update._meta : undefined);
501 return Option.isSome(subtask) && subtask.value.kiro.kind !== "agent-subtask"
502 ? { ...notification, sessionId: subtask.value.kiro.agentSubtaskId }
503 : notification;
505​
506/** The `invoke_sub_agent` call; it completes with the sub-agent's final response. */
507function extractKiroSubagentUpdate(tool: AcpToolCallState): AcpAdapterV2SubagentUpdate | undefined {
508 const subtask = decodeKiroAgentSubtask(tool.data.meta);
509 if (Option.isNone(subtask) || subtask.value.kiro.kind !== "agent-subtask") return undefined;
510 const input = decodeKiroSubagentInput(tool.data.rawInput);
511 const status =
512 tool.status === "failed" ? "failed" : tool.status === "completed" ? "completed" : "running";
513 return {
514 nativeTaskId: tool.toolCallId,
515 childSessionId: subtask.value.kiro.agentSubtaskId,
516 prompt: Option.isSome(input) ? input.value.prompt : (tool.title ?? "Kiro sub-agent"),
517 title: Option.isSome(input) ? input.value.name : null,
518 model: null,
519 status,
520 result: typeof tool.data.rawOutput === "string" ? tool.data.rawOutput : null,
521 };
523​
524/**
525 * Kiro's prompt errors carry a message meant for the user (live 2.27: -32000
526 * "The model 'x' is not available. Please select a different model and try
527 * again. (Request ID: …)"). Show it instead of the generic failure text;
528 * makeProviderFailure bounds and redacts it.
529 */
530function kiroPromptFailure(cause: unknown) {
531 return makeProviderFailure({
532 cause,
533 ...(isAcpRequestError(cause) ? { message: cause.errorMessage, code: String(cause.code) } : {}),
534 class: "provider_error",
535 });
537​
538function makeKiroAcpAdapterFlavor(options: KiroAdapterV2Options): AcpAdapterV2Flavor {
539 const makeRuntime =
540 options.makeRuntime ??
541 ((input: AcpAdapterV2RuntimeInput) => {
542 const { runtimePolicy: _runtimePolicy, processEnvironment, ...runtimeInput } = input;
543 return makeKiroAcpRuntime({
544 ...runtimeInput,
545 settings: options.settings,
546 environment: { ...options.environment, ...processEnvironment },
547 });
548 });
549 const stripSteeringNote = makeKiroSteeringNoteFilter();
550 return {
551 driver: KIRO_PROVIDER,
552 runtimeHarness: "Kiro",
553 capabilities: KiroProviderCapabilitiesV2,
554 // Kiro V3 loads the user's hooks only when the client opts in. `v2` makes
555 // Kiro run them itself; `enabled` alone hands them to the client as
556 // `_kiro/hooks/*` requests, which T3 does not answer.
557 clientCapabilitiesMeta: { kiro: { hooks: { enabled: true, v2: true } } },
558 makeRuntime,
559 promptFailure: kiroPromptFailure,
560 ignoreSessionUpdate: makeKiroCloudConfigFilter(),
561 normalizeSessionUpdate: (notification) =>
562 stripSteeringNote(normalizeKiroSessionUpdate(notification)),
563 extractSubagentUpdate: extractKiroSubagentUpdate,
564 // See applyKiroModelSelection: `model` arrives after session/new.
565 modelOptionArrivesLate: true,
566 applyModelSelection: applyKiroModelSelection(
567 options.defaultModel ?? Effect.succeed(KIRO_FALLBACK_DEFAULT_MODEL),
568 ),
569 // The generic option loop runs after the model write, and an agent write
570 // there would put the agent's own model back.
571 ownedModelOptionIds: ["reasoningEffort", KIRO_AGENT_CONFIG_ID],
572 // With Autopilot off (Supervised) Kiro asks the user to accept a turn's
573 // changes before it ends. T3's runtime policy answers its per-tool prompts
574 // either way, and a runtime-mode change reopens the session to reapply it.
575 sessionConfigForPolicy: (policy) => [
576 { id: KIRO_AUTOPILOT_CONFIG_ID, value: kiroAutopilotValue(policy) },
577 ],
578 // See isKiroPromptActivity: Kiro 2.27.1 can drop a cancel sent just after
579 // `session/prompt` and run the turn to completion.
580 cancelAfterPromptStarts: { isStart: isKiroPromptActivity, bound: KIRO_CANCEL_HOLD_BOUND },
581 permissionDisposition: kiroPermissionDisposition,
582 approvalOptions: kiroApprovalOptions,
583 compactSession: kiroCompactSession,
584 steering: kiroSteering,
585 forkFromTurn: kiroForkFromTurn,
586 // Kiro V3 advertises `promptCapabilities.image`; the shared adapter reads it.
587 ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
588 };
590​
591export const makeKiroAdapterV2 = Effect.fn("makeKiroAdapterV2")(function* (
592 options: KiroAdapterV2Options,
593) {
594 return yield* makeAcpAdapterV2({
595 instanceId: options.instanceId,
596 flavor: makeKiroAcpAdapterFlavor(options),
597 selfInvocation: options.selfInvocation,
598 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
599 ...(options.testHooks === undefined ? {} : { testHooks: options.testHooks }),
600 });
601});
602​
603export type KiroAdapterV2DriverEnv =
604 | ChildProcessSpawner.ChildProcessSpawner
605 | SecureRandom.SecureRandom
606 | FileSystem.FileSystem
607 | IdAllocator.IdAllocatorV2
608 | Path.Path
609 | McpProviderSessions.McpProviderSessions
610 | ProviderEventLoggers.ProviderEventLoggers
611 | ProviderHost.ProviderHost;
612​
613/** `defaultModel` reads Kiro's id for "Kiro default" from the provider snapshot. */
614export const makeKiroAdapterV2Driver = (
615 defaultModel: Effect.Effect<string>,
616): ProviderAdapterDriver<KiroSettings, KiroAdapterV2DriverEnv> => ({
617 driverKind: KIRO_PROVIDER,
618 configSchema: KiroSettings,
619 defaultConfig: (): KiroSettings => DEFAULT_KIRO_SETTINGS,
620 create: Effect.fn("KiroAdapterV2Driver.create")(
621 function* (input: ProviderAdapterDriverCreateInput<KiroSettings>) {
622 const hostEnvironment = yield* HostProcess.Environment;
623 const selfInvocation = yield* resolveSelfInvocation();
624 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
625 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
626 return yield* makeKiroAdapterV2({
627 instanceId: input.instanceId,
628 settings: { ...input.config, enabled: input.enabled },
629 environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
630 selfInvocation,
631 defaultModel,
632 nativeLogging: (threadId) =>
633 makeNativeLogger({
634 nativeEventLogger: providerEventLoggers.native,
635 provider: KIRO_PROVIDER,
636 threadId,
637 }),
638 });
639 },
640 (effect, input) =>
641 effect.pipe(
642 Effect.mapError(
643 (cause) =>
644 new ProviderAdapterDriverCreateError({
645 driver: KIRO_PROVIDER,
646 instanceId: input.instanceId,
647 detail: "Failed to create Kiro ACP adapter.",
648 cause,
649 }),
650 ),
651 ),
652 ),
653});