Connect an explicitly paired Claude Code session to AgentKib on this Mac.

First-party local Mods plugin for the exact Claude Code 2.1.301 contract. It connects AgentKib's existing chat interface to the terminal's original execution process. It never starts a replacement Claude process, resumes another UUID, calls a model itself, or changes the user's permission rules.
Use AgentKib's connection panel for the selected session. Register the bundled agentkib-local marketplace and install agentkib-bridge@agentkib-local using the commands displayed there. In an existing terminal, run /reload-plugins to load the plugin, then execute the one-time /agentkib-connect … command generated by AgentKib. For isolated development only, claude --plugin-dir <this-directory> loads the plugin for that invocation without installing it.
Only the native session UUID and working directory selected in AgentKib can pair. The CLI version must match exactly. An unknown version or an unconfirmed native activity state leaves sending disabled. /agentkib-status is a local command and does not call a model.
$.prompt.submit has no atomic idle/CAS contract and skips this plugin's own hook by re-entry; a competing terminal submission cannot be guaranteed never to queue. The retained correlation implementation is an isolated protocol experiment only. Renderer, Web and plugin configuration cannot enable it.turn.start using $.turn.abort.Snapshots are previews of the latest 80 text rows and 100 tool records, subject to a byte budget. Clipped text has an explicit marker. Complete history continues to use AgentKib's original history provider. Hidden thinking is never forwarded.
Native cancellation and reading are verified separately from experimental send correlation. Approval and question completion remain unavailable in AgentKib. A bounded event buffer retains turn-completion evidence until ACK, including across reconnect; an unknown send is never unlocked merely because the TUI is idle.
The connection uses HTTP over a private local Unix socket. Persistent credentials are written by Backend with mode 0600. The plugin reads that file through the native API; its store keeps only the credential file path. Tokens do not enter commands, URLs, or the chat UI. Native identity changes and session end invalidate the lease; Runtime owns persistent request receipts and unknown outcome recovery.
claude plugin validate ./packages/claude-bridge-plugin --strict
claude plugin test ./packages/claude-bridge-plugin
pnpm --filter @agentkib/desktop exec vitest run test/claude-bridge-plugin.test.ts
The official plugin tests need no sign-in, network or model. An optional macOS integration test starts the installed TUI in an isolated config directory and uses a synthetic localhost provider with no real credentials:
AGENTKIB_CLAUDE_BRIDGE_NATIVE=1 \
pnpm --filter @agentkib/desktop exec vitest run test/claude-bridge-native.test.ts
Set AGENTKIB_CLAUDE_EXECUTABLE when the installed binary is elsewhere, and AGENTKIB_NATIVE_BRIDGE_EVIDENCE_DIR to retain redacted terminal output and the binary/type identity. For the separate send-correlation experiment only, set AGENTKIB_CLAUDE_BRIDGE_SEND_EXPERIMENT=1 on this test command: the test changes one literal in its temporary plugin copy, never the shipped plugin. Passing that experiment does not open production send capability. This verifies transport and native execution contracts; it does not substitute for real provider or physical phone acceptance.
Official references: Mods, API and events, native test kit.
hooks/register.js 1056 lines1// Claude Code 2.1.301 Mods API. Host calls stay explicit for plugin validate.
2const CLI_VERSION = "2.1.301";
3const PLUGIN_VERSION = "0.1.0";
4// Native prompt.submit has no atomic idle/CAS option. Production sending stays
5// closed; only an isolated test COPY replaces this literal to study the contract.
6const SEND_CONTRACT_EXPERIMENT = false;
7const MAX_STREAM = 96 * 1024;
8const MAX_EVENTS = 256;
9const PREVIEW_MARKER = "\n[AgentKib preview truncated; read the original history for full content]";
10function utf8Prefix(value, budget) {
11 let read = 0;
12 let written = 0;
13 // The pinned Mods runtime only promises TextEncoder.encode, not encodeInto.
14 // Count a bounded prefix directly without allocating an encoder per character.
15 while (read < value.length) {
16 const unit = value.charCodeAt(read);
17 let units = 1;
18 let width = unit < 0x80 ? 1 : unit < 0x800 ? 2 : 3;
19 if (unit >= 0xd800 && unit <= 0xdbff) {
20 const next = value.charCodeAt(read + 1);
21 if (next >= 0xdc00 && next <= 0xdfff) {
22 units = 2;
23 width = 4;
24 }
25 }
26 if (written + width > budget) break;
27 read += units;
28 written += width;
29 }
30 return { read, written };
31}
32
33export function previewText(value, budget = 16 * 1024) {
34 if (typeof value !== "string") return "";
35 // Every UTF-16 code unit costs at most three UTF-8 bytes. Small chunks need
36 // neither an allocation nor an encoding pass just to prove they fit.
37 if (value.length * 3 <= budget || utf8Prefix(value, budget).read === value.length) return value;
38 // The marker is ASCII; the prefix count never splits a surrogate pair.
39 // Slice the original text so even an unpaired surrogate is passed unchanged.
40 const marker = PREVIEW_MARKER.slice(0, Math.max(0, budget));
41 const { read } = utf8Prefix(value, Math.max(0, budget - marker.length));
42 return value.slice(0, read) + marker;
43}
44
45function appendStreamText(state, text) {
46 if (state.streamTruncated) return;
47 const last = state.streamText.charCodeAt(state.streamText.length - 1);
48 const first = text.charCodeAt(0);
49 // Two lone-surrogate encodings cost six bytes; their joined pair costs four.
50 const joined = last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff;
51 const repair = joined ? 2 : 0;
52 const { read, written } = utf8Prefix(text, MAX_STREAM - state.streamBytes + repair);
53 if (read === text.length) {
54 state.streamText += text;
55 state.streamBytes += written - repair;
56 } else {
57 state.streamText = previewText(state.streamText + text, MAX_STREAM);
58 state.streamTruncated = true;
59 }
60}
61
62export function safeSocket(path) {
63 return (
64 typeof path === "string" &&
65 path.startsWith("/") &&
66 !path.includes("\0") &&
67 !path.split("/").includes("..") &&
68 new TextEncoder().encode(path).length <= 100
69 );
70}
71
72export function decodeTicket(text) {
73 if (typeof text !== "string" || !/^[A-Za-z0-9_-]{1,4096}$/.test(text))
74 throw new Error("invalid-ticket");
75 const bytes = Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/")), (c) =>
76 c.charCodeAt(0),
77 );
78 const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
79 if (
80 value?.protocol !== 1 ||
81 typeof value.ticket !== "string" ||
82 !/^[A-Za-z0-9_-]{24,256}$/.test(value.ticket) ||
83 !safeSocket(value.socketPath)
84 )
85 throw new Error("invalid-ticket");
86 return { protocol: 1, socketPath: value.socketPath, ticket: value.ticket };
87}
88
89export function validSend(command) {
90 return (
91 typeof command?.text === "string" &&
92 command.text.trim().length > 0 &&
93 !command.text.trimStart().startsWith("/") &&
94 [...command.text].length <= 16_000 &&
95 new TextEncoder().encode(command.text).length <= 16_384 &&
96 typeof command.requestId === "string" &&
97 command.requestId.length > 0 &&
98 command.requestId.length <= 256
99 );
100}
101
102function boundedJson(value) {
103 try {
104 return previewText(JSON.stringify(value ?? {}));
105 } catch {
106 return "[unavailable]";
107 }
108}
109
110export function visibleMessages(rows) {
111 if (!Array.isArray(rows)) throw new Error("invalid-native-history");
112 return rows
113 .filter(
114 (row) => (row?.role === "user" || row?.role === "assistant") && typeof row.text === "string",
115 )
116 .slice(-80)
117 .map((row) => ({ role: row.role, text: previewText(row.text, 8 * 1024) }));
118}
119
120export function visibleTools(rows) {
121 if (!Array.isArray(rows)) throw new Error("invalid-native-history");
122 return rows
123 .flatMap((row) => (Array.isArray(row?.toolUses) ? row.toolUses : []))
124 .filter((tool) => typeof tool?.tool_use_id === "string" && typeof tool.tool === "string")
125 .slice(-100)
126 .map((tool) => ({
127 id: tool.tool_use_id,
128 name: tool.tool,
129 input: boundedJson(tool.input),
130 ...(typeof tool.text === "string" ? { output: previewText(tool.text) } : {}),
131 status: tool.isError
132 ? "failed"
133 : tool.result !== undefined || typeof tool.text === "string"
134 ? "completed"
135 : "running",
136 }));
137}
138
139export function initialState() {
140 return {
141 identity: null,
142 instanceId: crypto.randomUUID(),
143 connection: null,
144 socketPath: null,
145 credentialPath: null,
146 revision: 0,
147 seq: 0,
148 events: [],
149 completions: new Map(),
150 generation: 0,
151 results: new Map(),
152 unacknowledgedResults: new Map(),
153 afterCommandId: null,
154 currentTurnId: null,
155 streamText: "",
156 streamBytes: 0,
157 streamTruncated: false,
158 nativeWorking: null,
159 activeTools: new Map(),
160 waits: new Map(),
161 // A reload can happen mid-turn. Only native activity events establish idle.
162 activityVerified: false,
163 bufferPaused: false,
164 overflowEpoch: 0,
165 snapshotRecovery: null,
166 sendDisabled: false,
167 pendingSend: null,
168 dirty: true,
169 pumping: false,
170 flushing: false,
171 closed: false,
172 needsReconnect: false,
173 lastSnapshot: null,
174 termination: null,
175 terminating: false,
176 };
177}
178
179function lease(state) {
180 if (!state.identity || !state.connection) throw new Error("not-connected");
181 return {
182 bindingId: state.connection.bindingId,
183 leaseId: state.connection.leaseId,
184 nativeId: state.identity.nativeId,
185 cwd: state.identity.cwd,
186 instanceId: state.instanceId,
187 };
188}
189
190async function identity($, state) {
191 const nativeId = await $.session.id();
192 const cwd = await $.session.cwd();
193 const version = await $.session.version();
194 if (
195 typeof nativeId !== "string" ||
196 !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(nativeId) ||
197 typeof cwd !== "string" ||
198 !cwd.startsWith("/") ||
199 version?.version !== CLI_VERSION
200 )
201 throw new Error("unsupported-native-identity");
202 if (state.identity && (state.identity.nativeId !== nativeId || state.identity.cwd !== cwd)) {
203 invalidate(state, "native-identity-changed");
204 void flush($, state);
205 throw new Error("native-identity-changed");
206 }
207 state.identity = {
208 protocol: 1,
209 nativeId,
210 cwd,
211 cliVersion: CLI_VERSION,
212 instanceId: state.instanceId,
213 pluginVersion: PLUGIN_VERSION,
214 };
215 return state.identity;
216}
217
218function record(state, event) {
219 if (state.closed || (!state.connection && !state.credentialPath)) return;
220 if (event.type === "turn-complete") {
221 if (state.completions.size >= MAX_EVENTS && !state.completions.has(event.turnId)) {
222 invalidate(state, "completion-capacity-reached");
223 return;
224 }
225 state.completions.set(event.turnId, event);
226 }
227 if (state.bufferPaused && event.type !== "snapshot") {
228 // A lost start cannot be followed by deltas against the host's old turn.
229 // Snapshots reconstruct previews; only terminal evidence bypasses the pause.
230 state.overflowEpoch += 1;
231 state.dirty = true;
232 return;
233 }
234 if (state.events.length >= MAX_EVENTS) {
235 markGap(state);
236 return;
237 }
238 const seq = ++state.seq;
239 state.events.push({ seq, event });
240 return seq;
241}
242
243function markGap(state) {
244 // Replace unacknowledged previews, retaining terminal evidence separately.
245 // New sequence numbers keep an older in-flight ACK from deleting this fence.
246 state.events = [{ seq: ++state.seq, event: { type: "gap", revision: state.revision } }];
247 state.bufferPaused = true;
248 state.overflowEpoch += 1;
249 state.snapshotRecovery = null;
250 state.dirty = true;
251}
252
253function waitingState(state) {
254 const waits = [...state.waits.values()];
255 if (waits.includes("question")) return "question";
256 if (waits.includes("permission")) return "permission";
257 return waits.includes("unknown") ? "unknown" : null;
258}
259
260function sameNativeValue(left, right) {
261 if (left === right) return true;
262 if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
263 const keys = Object.keys(left);
264 return (
265 Array.isArray(left) === Array.isArray(right) &&
266 keys.length === Object.keys(right).length &&
267 keys.every((key) => Object.hasOwn(right, key) && sameNativeValue(left[key], right[key]))
268 );
269}
270
271function permissionTool(state, event) {
272 if (!event.tool_input || typeof event.tool_input !== "object") return null;
273 // PermissionRequest has no native tool-use ID. Associate only an unambiguous
274 // matching call; rewritten or duplicate inputs remain explicitly unverified.
275 const matches = [...state.activeTools.values()].filter(
276 (tool) =>
277 tool.name === event.tool_name &&
278 Object.keys(event.tool_input).every(
279 (key) =>
280 Object.hasOwn(tool.input, key) && sameNativeValue(event.tool_input[key], tool.input[key]),
281 ),
282 );
283 return matches.length === 1 ? matches[0] : null;
284}
285
286function invalidate(state, reason) {
287 state.generation += 1;
288 state.sendDisabled = true;
289 state.activityVerified = false;
290 state.nativeWorking = null;
291 state.currentTurnId = null;
292 state.streamText = "";
293 state.streamBytes = 0;
294 state.streamTruncated = false;
295 state.activeTools.clear();
296 state.waits.clear();
297 state.revision += 1;
298 if (state.connection && state.identity && state.lastSnapshot) {
299 const revoked = {
300 ...state.lastSnapshot,
301 revision: state.revision,
302 status: "disconnected",
303 streamText: "",
304 currentTurnId: null,
305 waiting: null,
306 messages: [],
307 toolItems: [],
308 usage: null,
309 capabilities: { read: false, send: false, stop: false, approve: false, answer: false },
310 };
311 state.termination = {
312 socketPath: state.socketPath,
313 token: state.connection.token,
314 body: {
315 ...lease(state),
316 events: [
317 { seq: ++state.seq, event: { type: "snapshot", snapshot: revoked } },
318 { seq: ++state.seq, event: { type: "invalidated", reason, revision: state.revision } },
319 ],
320 },
321 };
322 }
323 state.closed = true;
324 state.connection = null;
325}
326
327async function request($, state, path, body, authenticated = true) {
328 if (!safeSocket(state.socketPath)) throw new Error("invalid-socket");
329 const connection = state.connection;
330 let response;
331 try {
332 response = await $.http.fetch(`http://localhost${path}`, {
333 socketPath: state.socketPath,
334 method: "POST",
335 headers: {
336 "Content-Type": "application/json",
337 ...(authenticated ? { Authorization: `Bearer ${state.connection?.token ?? ""}` } : {}),
338 },
339 body: JSON.stringify(body),
340 });
341 } catch {
342 if (state.connection === connection) state.needsReconnect = true;
343 throw new Error("bridge-unavailable");
344 }
345 if (authenticated && state.connection !== connection) throw new Error("stale-bridge-response");
346 const decoded = typeof response?.text === "string" ? JSON.parse(response.text) : null;
347 if (response?.status === 409 && decoded?.error === "bridge-lease-expired") {
348 state.needsReconnect = true;
349 throw new Error("bridge-lease-expired");
350 }
351 if ([401, 403, 410].includes(response?.status)) {
352 invalidate(state, "binding-revoked");
353 throw new Error("binding-revoked");
354 }
355 if (!response?.ok || typeof response.text !== "string") throw new Error("bridge-unavailable");
356 return decoded;
357}
358
359function connectionFrom(value) {
360 if (
361 value?.protocol !== 1 ||
362 ![value.bindingId, value.token, value.leaseId].every(
363 (v) => typeof v === "string" && v.length >= 16 && v.length <= 512,
364 ) ||
365 typeof value.credentialPath !== "string" ||
366 !value.credentialPath.startsWith("/") ||
367 value.credentialPath.includes("\0") ||
368 value.credentialPath.split("/").includes("..")
369 )
370 throw new Error("invalid-bridge-connection");
371 return value;
372}
373
374async function snapshot($, state) {
375 const observed = await identity($, state);
376 const rows = await $.session.messages();
377 const usage = await $.session.usage();
378 const context = usage?.context;
379 const numeric = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
380 const waiting = waitingState(state);
381 const result = {
382 ...observed,
383 revision: state.revision,
384 currentTurnId: state.currentTurnId,
385 status:
386 waiting === "question" || waiting === "permission"
387 ? "waiting"
388 : state.currentTurnId || state.nativeWorking !== false || waiting === "unknown"
389 ? "running"
390 : "idle",
391 waiting,
392 messages: visibleMessages(rows),
393 toolItems: visibleTools(rows),
394 streamText: state.streamText,
395 usage: context
396 ? {
397 tokens: numeric(context.tokens),
398 window: numeric(context.window),
399 percent: numeric(context.percent),
400 observedAt: Date.now(),
401 }
402 : null,
403 capabilities: {
404 read: true,
405 send:
406 SEND_CONTRACT_EXPERIMENT &&
407 !state.closed &&
408 !state.needsReconnect &&
409 state.activityVerified &&
410 !state.sendDisabled &&
411 !state.bufferPaused &&
412 !state.currentTurnId &&
413 !state.pendingSend,
414 stop:
415 !state.closed &&
416 !state.needsReconnect &&
417 !state.bufferPaused &&
418 Boolean(state.currentTurnId),
419 approve: false,
420 answer: false,
421 },
422 };
423 // JSON escaping counts too: a body of control characters must fit the same
424 // transport budget as ordinary text. This is a preview, never the full log.
425 while (new TextEncoder().encode(JSON.stringify(result)).length > 512 * 1024) {
426 if (result.messages.length > 1) result.messages.shift();
427 else if (result.toolItems.length) result.toolItems.shift();
428 else {
429 result.streamText = previewText(result.streamText, 8 * 1024);
430 break;
431 }
432 }
433 state.lastSnapshot = result;
434 return result;
435}
436
437async function flush($, state) {
438 if (state.termination && !state.terminating) {
439 state.terminating = true;
440 const ending = state.termination;
441 // End the local lease immediately; this best-effort notification uses only
442 // the old lease and does not read the newly selected native session.
443 try {
444 await $.http.fetch("http://localhost/v1/events", {
445 socketPath: ending.socketPath,
446 method: "POST",
447 headers: { "Content-Type": "application/json", Authorization: `Bearer ${ending.token}` },
448 body: JSON.stringify(ending.body),
449 });
450 } catch {
451 /* A closed socket is expected during application shutdown. */
452 }
453 state.termination = null;
454 state.terminating = false;
455 return;
456 }
457 if (
458 !state.connection ||
459 state.connection.leaseId === "reconnecting" ||
460 state.closed ||
461 state.flushing
462 )
463 return;
464 state.flushing = true;
465 try {
466 // Terminal evidence survives delta overflow and reconnect until acknowledged.
467 for (const event of state.completions.values()) {
468 if (state.events.length >= MAX_EVENTS) break;
469 if (!state.events.some((entry) => entry.event === event))
470 state.events.push({ seq: ++state.seq, event });
471 }
472 if (state.dirty && state.events.length < MAX_EVENTS) {
473 state.dirty = false;
474 const epoch = state.overflowEpoch;
475 const seq = record(state, { type: "snapshot", snapshot: await snapshot($, state) });
476 if (seq !== undefined) state.snapshotRecovery = { seq, epoch };
477 }
478 const batch = [];
479 for (const entry of state.events.slice(0, 32)) {
480 if (new TextEncoder().encode(JSON.stringify([...batch, entry])).length > 900 * 1024) break;
481 batch.push(entry);
482 }
483 if (!batch.length) return;
484 const connection = state.connection;
485 const response = await request($, state, "/v1/events", { ...lease(state), events: batch });
486 if (state.connection !== connection) return;
487 if (
488 !Number.isSafeInteger(response?.ackSeq) ||
489 response.ackSeq !== batch[batch.length - 1].seq
490 ) {
491 invalidate(state, "invalid-event-acknowledgement");
492 return;
493 }
494 for (const entry of batch) {
495 if (
496 entry.event.type === "turn-complete" &&
497 state.completions.get(entry.event.turnId) === entry.event
498 )
499 state.completions.delete(entry.event.turnId);
500 }
501 state.events = state.events.filter((entry) => entry.seq > response.ackSeq);
502 if (
503 state.bufferPaused &&
504 state.snapshotRecovery &&
505 state.snapshotRecovery.seq <= response.ackSeq &&
506 state.snapshotRecovery.epoch === state.overflowEpoch
507 ) {
508 state.bufferPaused = false;
509 state.dirty = true;
510 }
511 } catch {
512 state.dirty = true;
513 } finally {
514 state.flushing = false;
515 }
516}
517
518async function reportResult($, state, result) {
519 if (!state.connection || state.closed) return;
520 await request($, state, "/v1/results", { ...lease(state), ...result });
521 state.unacknowledgedResults.delete(result.commandId);
522}
523
524async function execute($, state, command) {
525 if (
526 !command ||
527 typeof command.commandId !== "string" ||
528 !command.commandId ||
529 command.commandId.length > 256
530 ) {
531 invalidate(state, "invalid-command");
532 return;
533 }
534 const prior = state.results.get(command.commandId);
535 const fingerprint = JSON.stringify(command);
536 if (prior) {
537 if (prior.fingerprint !== fingerprint) {
538 invalidate(state, "command-identity-conflict");
539 return;
540 }
541 await reportResult($, state, prior.result);
542 state.afterCommandId = command.commandId;
543 return;
544 }
545 const result = {
546 commandId: command.commandId,
547 status: "rejected",
548 reason: "unsupported-command",
549 revision: state.revision,
550 };
551 // Lost acknowledgements replay this receipt, never a native action.
552 state.results.set(command.commandId, { fingerprint, result });
553 if (state.results.size > 2048) {
554 invalidate(state, "receipt-capacity-reached");
555 return;
556 }
557 await identity($, state);
558 if (command.expectedRevision !== state.revision) result.reason = "revision-conflict";
559 else if (command.kind === "send") {
560 if (!SEND_CONTRACT_EXPERIMENT) result.reason = "claude-native-send-experimental-disabled";
561 else if (!validSend(command)) result.reason = "invalid-text";
562 else if (
563 state.closed ||
564 state.sendDisabled ||
565 state.bufferPaused ||
566 !state.activityVerified ||
567 state.currentTurnId ||
568 state.pendingSend
569 )
570 result.reason = "session-not-idle";
571 else {
572 const pending = {
573 text: command.text,
574 expectedRevision: command.expectedRevision,
575 observed: false,
576 competing: false,
577 turnId: null,
578 };
579 state.pendingSend = pending;
580 try {
581 const submitted = await $.prompt.submit({ text: command.text, asUser: true });
582 if (submitted?.drop) result.reason = "native-prompt-refused";
583 else if (
584 state.pendingSend === pending &&
585 pending.observed &&
586 !pending.competing &&
587 pending.turnId &&
588 submitted?.text === pending.text
589 ) {
590 result.status = "accepted";
591 delete result.reason;
592 result.turnId = pending.turnId;
593 } else {
594 result.status = "unknown";
595 result.reason = "native-turn-unconfirmed";
596 state.sendDisabled = true;
597 }
598 } catch {
599 result.status = "unknown";
600 result.reason = "native-submit-result-unknown";
601 state.sendDisabled = true;
602 } finally {
603 if (state.pendingSend === pending) state.pendingSend = null;
604 }
605 }
606 } else if (command.kind === "stop") {
607 if (state.closed || state.bufferPaused || state.needsReconnect)
608 result.reason = "session-state-unverified";
609 else if (!state.currentTurnId || command.turnId !== state.currentTurnId)
610 result.reason = "turn-conflict";
611 else {
612 try {
613 await $.turn.abort({ turnId: command.turnId });
614 result.status = "accepted";
615 delete result.reason;
616 result.turnId = command.turnId;
617 } catch {
618 result.status = "unknown";
619 result.reason = "native-stop-result-unknown";
620 }
621 }
622 }
623 result.revision = state.revision;
624 state.unacknowledgedResults.set(command.commandId, result);
625 state.dirty = true;
626 await flush($, state);
627 await reportResult($, state, result);
628 state.afterCommandId = command.commandId;
629}
630
631async function pump($, state) {
632 if (
633 (!state.connection && !state.needsReconnect) ||
634 state.connection?.leaseId === "reconnecting" ||
635 state.closed ||
636 state.pumping
637 )
638 return;
639 state.pumping = true;
640 try {
641 if (state.needsReconnect && !state.pendingSend) await reconnect($, state);
642 if (!state.connection || state.closed) return;
643 await identity($, state);
644 for (const result of state.unacknowledgedResults.values()) await reportResult($, state, result);
645 const connection = state.connection;
646 const response = await request($, state, "/v1/commands", {
647 ...lease(state),
648 afterCommandId: state.afterCommandId,
649 waitMs: 20_000,
650 });
651 if (state.closed || state.connection !== connection) return;
652 if (!Array.isArray(response?.commands) || response.commands.length > 1) {
653 invalidate(state, "invalid-command-batch");
654 return;
655 }
656 for (const command of response.commands) await execute($, state, command);
657 } catch {
658 state.dirty = true;
659 } finally {
660 state.pumping = false;
661 }
662}
663
664async function connect($, state, ticket) {
665 // A new one-time ticket is the explicit authorization to bind the currently
666 // selected native session after clear/resume or a prior disconnect.
667 if (!state.connection && state.closed && !state.pendingSend) state.identity = null;
668 if (state.connection && state.connection.leaseId !== "reconnecting")
669 throw new Error("already-connected");
670 const generation = ++state.generation;
671 state.connection = null;
672 const observed = await identity($, state);
673 if (state.generation !== generation) throw new Error("stale-connect");
674 state.socketPath = ticket.socketPath;
675 const response = await request(
676 $,
677 state,
678 "/v1/pair",
679 { ...observed, ticket: ticket.ticket },
680 false,
681 );
682 if (state.generation !== generation) throw new Error("stale-connect");
683 state.connection = connectionFrom(response);
684 state.credentialPath = response.credentialPath;
685 state.closed = false;
686 state.sendDisabled = false;
687 state.needsReconnect = false;
688 state.seq = 0;
689 state.events = [];
690 state.completions.clear();
691 state.bufferPaused = false;
692 state.snapshotRecovery = null;
693 state.afterCommandId = null;
694 await $.store.set(`credentialPath:${observed.nativeId}`, response.credentialPath);
695 state.dirty = true;
696 await flush($, state);
697}
698
699async function reconnect($, state) {
700 const generation = state.generation;
701 const observed = await identity($, state);
702 const path = await $.store.get(`credentialPath:${observed.nativeId}`);
703 if (typeof path !== "string" || !path.startsWith("/") || path.includes("\0")) return;
704 const credential = JSON.parse(await $.fs.read(path));
705 if (
706 credential?.protocol !== 1 ||
707 credential.nativeId !== observed.nativeId ||
708 credential.cwd !== observed.cwd ||
709 credential.cliVersion !== CLI_VERSION ||
710 credential.pluginVersion !== PLUGIN_VERSION ||
711 !safeSocket(credential.socketPath) ||
712 typeof credential.bindingId !== "string" ||
713 typeof credential.token !== "string"
714 )
715 throw new Error("invalid-reconnect-credential");
716 if (state.generation !== generation || state.closed) throw new Error("stale-reconnect");
717 state.socketPath = credential.socketPath;
718 state.credentialPath = path;
719 const connecting = {
720 protocol: 1,
721 bindingId: credential.bindingId,
722 token: credential.token,
723 leaseId: "reconnecting",
724 credentialPath: path,
725 };
726 state.connection = connecting;
727 const response = await request($, state, "/v1/reconnect", {
728 ...observed,
729 bindingId: credential.bindingId,
730 });
731 if (state.generation !== generation || state.closed || state.connection !== connecting)
732 throw new Error("stale-reconnect");
733 state.connection = connectionFrom(response);
734 state.credentialPath = path;
735 state.closed = false;
736 state.needsReconnect = false;
737 state.seq = 0;
738 state.events = [];
739 state.snapshotRecovery = null;
740 state.afterCommandId = null;
741 state.dirty = true;
742}
743
744export function register(on) {
745 const state = initialState();
746 on("session.start", async ($, e, next) => {
747 await $.command.register({
748 name: "agentkib-connect",
749 description: "Connect this session using the one-time ticket shown in AgentKib",
750 argumentHint: "<one-time-ticket>",
751 immediate: true,
752 });
753 await $.command.register({
754 name: "agentkib-status",
755 description: "Show local AgentKib connection status without calling a model",
756 immediate: true,
757 });
758 $.clock.every(250, () => flush($, state));
759 $.clock.every(250, () => pump($, state));
760 const generation = state.generation;
761 // Offline Runtime must not delay startup of the original terminal. A later
762 // session.end or explicit pairing invalidates this background generation.
763 void reconnect($, state).catch(() => {
764 if (state.generation === generation) {
765 state.connection = null;
766 state.needsReconnect = true;
767 }
768 });
769 return next(e);
770 });
771 on("command.run", { command: "agentkib-connect" }, async ($, e) => {
772 try {
773 await connect($, state, decodeTicket(e.args.trim()));
774 return {
775 text: "AgentKib connected to this Claude Code session. Approvals and questions stay here.",
776 };
777 } catch {
778 return {
779 text: "AgentKib could not connect. Generate a new ticket and check the CLI version and workspace.",
780 };
781 }
782 });
783 on("command.run", { command: "agentkib-status" }, async () => ({
784 text:
785 state.connection && state.connection.leaseId !== "reconnecting" && !state.closed
786 ? "AgentKib is connected to this session. Approvals and questions remain in the original terminal."
787 : "AgentKib bridge is not connected.",
788 }));
789 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
790 if (typeof e.props?.isWorking === "boolean" && !state.pendingSend) {
791 const verified = !e.props.isWorking;
792 if (verified !== state.activityVerified || e.props.isWorking !== state.nativeWorking) {
793 state.activityVerified = verified;
794 state.nativeWorking = e.props.isWorking;
795 state.dirty = true;
796 }
797 }
798 return next(e);
799 });
800 on("ui.render", { component: "ToolProgress" }, async ($, e, next) => {
801 if (e.props?.kind === "background_hint" && typeof e.props.tool_use_id === "string") {
802 const tool = state.activeTools.get(e.props.tool_use_id);
803 if (tool) {
804 for (const owner of state.waits.keys()) {
805 if (owner.tool === tool) {
806 state.waits.delete(owner);
807 state.dirty = true;
808 }
809 }
810 }
811 }
812 return next(e);
813 });
814 on("prompt.submit", async ($, e, next) => {
815 const pending = state.pendingSend;
816 if (e.origin?.kind === "plugin" && e.origin.name === "agentkib-bridge") {
817 if (
818 !pending ||
819 state.closed ||
820 state.sendDisabled ||
821 state.currentTurnId ||
822 pending.expectedRevision !== state.revision ||
823 e.text !== pending.text ||
824 pending.competing
825 ) {
826 return { drop: "AgentKib session changed before dispatch; no queued retry was created." };
827 }
828 pending.observed = true;
829 } else if (pending) pending.competing = true;
830 return next(e);
831 }).catch(async ($, e, next) => {
832 if (next.called) throw new Error(next.error.message);
833 if (
834 next.error.kind === "re-entry" &&
835 next.error.cause === undefined &&
836 state.pendingSend &&
837 e.origin?.kind === "plugin" &&
838 e.origin.name === "agentkib-bridge"
839 ) {
840 const pending = state.pendingSend;
841 if (
842 state.closed ||
843 state.sendDisabled ||
844 state.currentTurnId ||
845 pending.expectedRevision !== state.revision ||
846 e.text !== pending.text ||
847 pending.competing
848 ) {
849 return { drop: "AgentKib session changed before dispatch; no queued retry was created." };
850 }
851 pending.observed = true;
852 return next(e);
853 }
854 if (!next.called && e.origin?.kind === "plugin" && e.origin.name === "agentkib-bridge") {
855 return { drop: "AgentKib could not verify this dispatch. It was not retried." };
856 }
857 return next(e);
858 });
859 on("turn.start", async ($, e, next) => {
860 if (e.agentId !== undefined) return next(e);
861 state.currentTurnId = e.turnId;
862 state.streamText = "";
863 state.streamBytes = 0;
864 state.streamTruncated = false;
865 state.nativeWorking = true;
866 state.activeTools.clear();
867 state.waits.clear();
868 state.activityVerified = true;
869 state.revision += 1;
870 const pending = state.pendingSend;
871 if (pending?.observed && !pending.competing && pending.text === e.text)
872 pending.turnId = e.turnId;
873 record(state, { type: "turn-start", turnId: e.turnId, revision: state.revision });
874 state.dirty = true;
875 return next(e);
876 });
877 on("turn.step", async function* ($, e, next) {
878 let recovered = false;
879 let recoveryReady = true;
880 if (e.agentId === undefined && state.currentTurnId === null) {
881 // Freshly loaded modules may miss turn.start. A native step establishes
882 // the actual turn, without inventing a start event or replaying a prompt.
883 recovered = true;
884 state.currentTurnId = e.turnId;
885 state.streamText = "";
886 state.streamBytes = 0;
887 state.streamTruncated = false;
888 state.nativeWorking = true;
889 state.revision += 1;
890 state.dirty = true;
891 try {
892 const view = await snapshot($, state);
893 if (state.currentTurnId === e.turnId)
894 recoveryReady = record(state, { type: "snapshot", snapshot: view }) !== undefined;
895 } catch {
896 recoveryReady = false;
897 if (!state.closed && (state.connection || state.credentialPath)) markGap(state);
898 }
899 }
900 return yield* observeNativeStream(next(e), (chunk) => {
901 if (
902 e.agentId === undefined &&
903 e.turnId === state.currentTurnId &&
904 chunk?.kind === "text" &&
905 typeof chunk.text === "string"
906 ) {
907 if (state.closed || (!state.connection && !state.credentialPath)) return;
908 appendStreamText(state, chunk.text);
909 if (!recovered || recoveryReady)
910 record(state, {
911 type: "text",
912 turnId: e.turnId,
913 text: state.bufferPaused ? "" : previewText(chunk.text, MAX_STREAM),
914 revision: state.revision,
915 });
916 else state.dirty = true;
917 }
918 });
919 });
920 on("turn.complete", async ($, e, next) => {
921 if (e.agentId !== undefined) return next(e);
922 if (e.turnId === state.currentTurnId || state.currentTurnId === null) {
923 state.currentTurnId = null;
924 state.nativeWorking = false;
925 state.activeTools.clear();
926 state.waits.clear();
927 state.activityVerified = true;
928 state.revision += 1;
929 }
930 // An end heard without a start is still evidence for the original receipt.
931 // A late old end never clears the state of a newer identified turn.
932 record(state, {
933 type: "turn-complete",
934 turnId: e.turnId,
935 text: previewText(e.answer, MAX_STREAM),
936 aborted: e.isAborted === true,
937 revision: state.revision,
938 });
939 state.dirty = true;
940 return next(e);
941 });
942 on("tool.call", async ($, e, next) => {
943 if (e.agentId !== undefined || !state.currentTurnId || typeof e.tool_use_id !== "string")
944 return next(e);
945 const tool = { id: e.tool_use_id, name: e.tool, input: boundedJson(e), status: "running" };
946 const active = { name: e.tool, input: e, turnId: state.currentTurnId };
947 state.activeTools.set(tool.id, active);
948 if (e.tool === "AskUserQuestion") state.waits.set(active, "question");
949 record(state, { type: "tool-start", tool, revision: state.revision });
950 state.dirty = true;
951 try {
952 const result = await next(e);
953 record(state, {
954 type: "tool-complete",
955 tool: {
956 ...tool,
957 status: result?.isError || result?.deny ? "failed" : "completed",
958 output: boundedJson(result),
959 },
960 revision: state.revision,
961 });
962 return result;
963 } finally {
964 if (state.activeTools.get(tool.id) === active) state.activeTools.delete(tool.id);
965 state.waits.delete(active);
966 for (const owner of state.waits.keys()) if (owner.tool === active) state.waits.delete(owner);
967 state.dirty = true;
968 }
969 }).catch(async ($, e, next) => {
970 if (next.called) throw new Error(next.error.message);
971 return next(e);
972 });
973 on("classic.PermissionRequest", async ($, e, next) => {
974 const owner = { tool: permissionTool(state, e), turnId: state.currentTurnId };
975 state.waits.set(owner, "permission");
976 state.dirty = true;
977 try {
978 // This classic hook runs before the native dialog. An empty answer does
979 // not prove whether a person is still being asked or the tool has begun.
980 const result = await next(e);
981 if (state.waits.has(owner)) {
982 if (["allow", "deny"].includes(result?.decision?.behavior)) state.waits.delete(owner);
983 else state.waits.set(owner, "unknown");
984 }
985 return result;
986 } catch (error) {
987 if (state.waits.has(owner)) state.waits.set(owner, "unknown");
988 throw error;
989 } finally {
990 state.dirty = true;
991 }
992 }).catch(async ($, e, next) => {
993 if (next.called) throw new Error(next.error.message);
994 return next(e);
995 });
996 on("session.append", async ($, e, next) => {
997 const pending = state.pendingSend;
998 if (e.agentId === undefined && pending && (e.door === "prompt" || e.door === "queue")) {
999 const text = Array.isArray(e.message?.content)
1000 ? e.message.content
1001 .filter((block) => block?.type === "text" && typeof block.text === "string")
1002 .map((block) => block.text)
1003 .join("\n")
1004 : "";
1005 if (
1006 e.origin?.kind === "plugin" &&
1007 e.origin.name === "agentkib-bridge" &&
1008 e.door === "prompt" &&
1009 text === pending.text
1010 ) {
1011 // Self-raised prompt.submit hooks are skipped by Claude's re-entry
1012 // guard. This engine-stamped row proves which prompt actually entered.
1013 pending.observed = true;
1014 } else {
1015 pending.competing = true;
1016 }
1017 }
1018 state.dirty = true;
1019 return next(e);
1020 }).catch(async ($, e, next) => {
1021 if (next.called) throw new Error(next.error.message);
1022 return next(e);
1023 });
1024 on("session.end", async ($, e, next) => {
1025 invalidate(state, "native-session-ended");
1026 // The original terminal must never wait for an offline AgentKib on exit,
1027 // clear or resume. The old lease notification is deliberately best-effort.
1028 void flush($, state);
1029 return next(e);
1030 });
1031}
1032
1033/** Delegation forwards next, return, throw and the final native value unchanged. */
1034export function observeNativeStream(stream, observe) {
1035 const item = async (result) => {
1036 const value = await result;
1037 if (!value.done) observe(value.value);
1038 return value;
1039 };
1040 return {
1041 [Symbol.asyncIterator]() {
1042 return this;
1043 },
1044 next(value) {
1045 return item(stream.next(value));
1046 },
1047 return(value) {
1048 return stream.return ? item(stream.return(value)) : Promise.resolve({ done: true, value });
1049 },
1050 throw(error) {
1051 if (stream.throw) return item(stream.throw(error));
1052 return Promise.reject(error);
1053 },
1054 };
1055}
1056