@kraki/tentacle 0.15.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,8 @@ Beyond bridging agent events, tentacle is responsible for several things that us
59
59
  - **Session lifecycle** — session create, update, and close are managed here
60
60
  - **Auto-approval** — tools on a local allowed list are approved automatically without user interaction
61
61
  - **Encryption** — all outgoing messages are encrypted before leaving the machine
62
+ - **Attachment storage** — image bytes produced by the agent are stored content-addressed on disk and streamed to receivers in chunks, not embedded inline in messages
63
+ - **Kraki MCP server** — a loopback HTTP server exposing tools the agent can call to surface images (`kraki-show_image`) and similar artifacts to user-facing devices
62
64
 
63
65
  The relay is a thin forwarder. Tentacle and the frontend own the application logic.
64
66
 
@@ -43,6 +43,12 @@ export interface ToolCompleteEvent {
43
43
  success?: boolean;
44
44
  attachments?: import('@kraki/protocol').Attachment[];
45
45
  }
46
+ /** Emitted alongside a tool_complete that carries one or more
47
+ * `AttachmentRef`s. Tells the runtime (RelayClient) to broadcast the bytes
48
+ * to all connected devices as `attachment_data` chunks. */
49
+ export interface AttachmentBytesEvent {
50
+ refs: Array<import('@kraki/protocol').AttachmentRef>;
51
+ }
46
52
  export interface SessionEndedEvent {
47
53
  reason: string;
48
54
  }
@@ -78,6 +84,9 @@ export declare abstract class AgentAdapter {
78
84
  onQuestionRequest: ((sessionId: string, event: QuestionRequestEvent) => void) | null;
79
85
  onToolStart: ((sessionId: string, event: ToolStartEvent) => void) | null;
80
86
  onToolComplete: ((sessionId: string, event: ToolCompleteEvent) => void) | null;
87
+ /** Called immediately after onToolComplete when bytes need to be pushed
88
+ * (broadcast as `attachment_data` chunks) to connected devices. */
89
+ onAttachmentBytes: ((sessionId: string, event: AttachmentBytesEvent) => void) | null;
81
90
  onIdle: ((sessionId: string) => void) | null;
82
91
  onError: ((sessionId: string, event: ErrorEvent) => void) | null;
83
92
  onSessionEnded: ((sessionId: string, event: SessionEndedEvent) => void) | null;
@@ -20,6 +20,9 @@ export class AgentAdapter {
20
20
  onQuestionRequest = null;
21
21
  onToolStart = null;
22
22
  onToolComplete = null;
23
+ /** Called immediately after onToolComplete when bytes need to be pushed
24
+ * (broadcast as `attachment_data` chunks) to connected devices. */
25
+ onAttachmentBytes = null;
23
26
  onIdle = null;
24
27
  onError = null;
25
28
  onSessionEnded = null;
@@ -1 +1 @@
1
- {"version":3,"file":"base.js","sourceRoot":"","sources":["../../src/adapters/base.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAmFH,2DAA2D;AAE3D,MAAM,OAAgB,YAAY;IAChC,kDAAkD;IAElD,gBAAgB,GAAkD,IAAI,CAAC;IACvE,SAAS,GAA8D,IAAI,CAAC;IAC5E,cAAc,GAAmE,IAAI,CAAC;IACtF,mBAAmB,GAAwE,IAAI,CAAC;IAChG,qHAAqH;IACrH,wBAAwB,GAAqG,IAAI,CAAC;IAClI,sBAAsB,GAA6D,IAAI,CAAC;IACxF,iBAAiB,GAAsE,IAAI,CAAC;IAC5F,WAAW,GAAgE,IAAI,CAAC;IAChF,cAAc,GAAmE,IAAI,CAAC;IACtF,MAAM,GAAyC,IAAI,CAAC;IACpD,OAAO,GAA4D,IAAI,CAAC;IACxE,cAAc,GAAmE,IAAI,CAAC;IACtF,iFAAiF;IACjF,cAAc,GAAwD,IAAI,CAAC;IAC3E,+DAA+D;IAC/D,aAAa,GAA8D,IAAI,CAAC;IAehF,iEAAiE;IACjE,KAAK,CAAC,WAAW,CAAC,eAAuB,EAAE,YAAoB;QAC7D,wFAAwF;QACxF,OAAO,IAAI,CAAC,aAAa,CAAC,YAAY,CAAC,CAAC;IAC1C,CAAC;IA0BD,mFAAmF;IACnF,KAAK,CAAC,YAAY,CAAC,UAAkB,IAA0C,CAAC;IAKhF,4DAA4D;IAC5D,KAAK,CAAC,UAAU,KAAwB,OAAO,EAAE,CAAC,CAAC,CAAC;IAEpD,+EAA+E;IAC/E,KAAK,CAAC,gBAAgB,KAA6B,OAAO,EAAE,CAAC,CAAC,CAAC;IAE/D,wEAAwE;IACxE,cAAc,CAAC,UAAkB,EAAE,KAAkD,IAAiC,CAAC;IAEvH,6EAA6E;IAC7E,KAAK,CAAC,aAAa,CAAC,QAAkH,IAA4B,OAAO,IAAI,CAAC,CAAC,CAAC;IAEhL,mGAAmG;IACnG,KAAK,CAAC,eAAe,CAAC,UAAkB,EAAE,MAAc,EAAE,gBAAyB,IAA0C,CAAC;IAE9H,iFAAiF;IACjF,eAAe,CAAC,UAAkB,IAAyB,OAAO,IAAI,CAAC,CAAC,CAAC;IAEzE,iEAAiE;IACjE,eAAe,CAAC,UAAkB,EAAE,MAAoB,IAAiC,CAAC;CAC3F"}
1
+ {"version":3,"file":"base.js","sourceRoot":"","sources":["../../src/adapters/base.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AA0FH,2DAA2D;AAE3D,MAAM,OAAgB,YAAY;IAChC,kDAAkD;IAElD,gBAAgB,GAAkD,IAAI,CAAC;IACvE,SAAS,GAA8D,IAAI,CAAC;IAC5E,cAAc,GAAmE,IAAI,CAAC;IACtF,mBAAmB,GAAwE,IAAI,CAAC;IAChG,qHAAqH;IACrH,wBAAwB,GAAqG,IAAI,CAAC;IAClI,sBAAsB,GAA6D,IAAI,CAAC;IACxF,iBAAiB,GAAsE,IAAI,CAAC;IAC5F,WAAW,GAAgE,IAAI,CAAC;IAChF,cAAc,GAAmE,IAAI,CAAC;IACtF;wEACoE;IACpE,iBAAiB,GAAsE,IAAI,CAAC;IAC5F,MAAM,GAAyC,IAAI,CAAC;IACpD,OAAO,GAA4D,IAAI,CAAC;IACxE,cAAc,GAAmE,IAAI,CAAC;IACtF,iFAAiF;IACjF,cAAc,GAAwD,IAAI,CAAC;IAC3E,+DAA+D;IAC/D,aAAa,GAA8D,IAAI,CAAC;IAehF,iEAAiE;IACjE,KAAK,CAAC,WAAW,CAAC,eAAuB,EAAE,YAAoB;QAC7D,wFAAwF;QACxF,OAAO,IAAI,CAAC,aAAa,CAAC,YAAY,CAAC,CAAC;IAC1C,CAAC;IA0BD,mFAAmF;IACnF,KAAK,CAAC,YAAY,CAAC,UAAkB,IAA0C,CAAC;IAKhF,4DAA4D;IAC5D,KAAK,CAAC,UAAU,KAAwB,OAAO,EAAE,CAAC,CAAC,CAAC;IAEpD,+EAA+E;IAC/E,KAAK,CAAC,gBAAgB,KAA6B,OAAO,EAAE,CAAC,CAAC,CAAC;IAE/D,wEAAwE;IACxE,cAAc,CAAC,UAAkB,EAAE,KAAkD,IAAiC,CAAC;IAEvH,6EAA6E;IAC7E,KAAK,CAAC,aAAa,CAAC,QAAkH,IAA4B,OAAO,IAAI,CAAC,CAAC,CAAC;IAEhL,mGAAmG;IACnG,KAAK,CAAC,eAAe,CAAC,UAAkB,EAAE,MAAc,EAAE,gBAAyB,IAA0C,CAAC;IAE9H,iFAAiF;IACjF,eAAe,CAAC,UAAkB,IAAyB,OAAO,IAAI,CAAC,CAAC,CAAC;IAEzE,iEAAiE;IACjE,eAAe,CAAC,UAAkB,EAAE,MAAoB,IAAiC,CAAC;CAC3F"}
@@ -33,6 +33,21 @@ export declare class CopilotAdapter extends AgentAdapter {
33
33
  private idleTimers;
34
34
  /** Track tool start args by toolCallId for correlating with tool_complete */
35
35
  private pendingToolArgs;
36
+ /**
37
+ * Tool identity captured at tool.execution_start, keyed by toolCallId.
38
+ * The Copilot SDK omits these fields from tool.execution_complete events —
39
+ * only toolCallId is present there. We stash the names on start and look
40
+ * them up on complete (especially for `mcpServerName`/`mcpToolName`, which
41
+ * we use to recognise our own MCP presenter tools).
42
+ */
43
+ private pendingToolIdentity;
44
+ /**
45
+ * Tool calls in flight per session — reverse index so we can clean up
46
+ * pendingToolArgs/pendingToolIdentity when a session ends without each
47
+ * tool call completing (kill, abort, error mid-flight). Without this the
48
+ * two maps grow unbounded over a long-lived daemon.
49
+ */
50
+ private sessionToolCallIds;
36
51
  /** Expected model per session — detects involuntary model fallbacks by the CLI */
37
52
  private expectedModels;
38
53
  /** User's originally requested model — never updated on involuntary fallbacks */
@@ -50,11 +65,32 @@ export declare class CopilotAdapter extends AgentAdapter {
50
65
  * Measured P99 turn_end→turn_start gap is <5ms; 500ms is a safe margin.
51
66
  */
52
67
  private static readonly IDLE_FALLBACK_MS;
68
+ /** Set of attachment ids already broadcast for this session — prevents
69
+ * re-broadcasting bytes when the same image is shown twice. */
70
+ private broadcastedAttachmentIds;
53
71
  constructor(options?: {
54
72
  cliPath?: string;
73
+ /** Tentacle's attachment store. When set, the adapter externalises
74
+ * image bytes from `kraki-show_image` to it instead of carrying them
75
+ * inline in the broadcast envelope. */
76
+ attachmentStore?: import('../attachment-store.js').AttachmentStore;
77
+ /** When set, the adapter wires the Kraki MCP server into every Copilot
78
+ * session it creates/resumes, with the URL scoped per Kraki sessionId. */
79
+ krakiMcp?: {
80
+ urlForSession: (sessionId: string) => string;
81
+ bearerToken: string;
82
+ };
55
83
  });
84
+ private readonly attachmentStore?;
85
+ private readonly krakiMcp?;
56
86
  /** System prompt appended to the SDK's built-in prompt. See system-prompt.md for docs. */
57
87
  private static readonly SYSTEM_PROMPT;
88
+ /**
89
+ * Appended to the system prompt when the Kraki MCP server is wired in.
90
+ * Tools are exposed to the model with display names of the form
91
+ * `<server>-<tool>` (dash), confirmed via a live SDK spike.
92
+ */
93
+ private static readonly KRAKI_MCP_PROMPT;
58
94
  start(): Promise<void>;
59
95
  stop(): Promise<void>;
60
96
  createSession(config: CreateSessionConfig): Promise<{
@@ -16,7 +16,7 @@ import { execSync } from 'node:child_process';
16
16
  import { existsSync, readFileSync, writeFileSync, cpSync, mkdirSync, unlinkSync, readdirSync, symlinkSync, lstatSync } from 'node:fs';
17
17
  import * as moduleApi from 'node:module';
18
18
  import { homedir, tmpdir } from 'node:os';
19
- import { dirname, join } from 'node:path';
19
+ import { dirname, join, basename } from 'node:path';
20
20
  import { getKrakiHome } from '../config.js';
21
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
  import { isSea } from 'node:sea';
@@ -71,6 +71,17 @@ function isRecoverableSessionError(err) {
71
71
  const message = getErrorMessage(err);
72
72
  return message.includes('Session not found:') || message.includes('Connection is disposed');
73
73
  }
74
+ /** Defensive basename — strips any leading directory components and never
75
+ * throws. Used to populate AttachmentRef.name from the absolute path the
76
+ * agent passed to show_image. */
77
+ function basenameSafe(p) {
78
+ try {
79
+ return basename(p) || 'image';
80
+ }
81
+ catch {
82
+ return 'image';
83
+ }
84
+ }
74
85
  // ── Copilot configDir shadow ────────────────────────────
75
86
  //
76
87
  // The Copilot CLI loads persistent tool approval rules from
@@ -212,6 +223,21 @@ export class CopilotAdapter extends AgentAdapter {
212
223
  idleTimers = new Map();
213
224
  /** Track tool start args by toolCallId for correlating with tool_complete */
214
225
  pendingToolArgs = new Map();
226
+ /**
227
+ * Tool identity captured at tool.execution_start, keyed by toolCallId.
228
+ * The Copilot SDK omits these fields from tool.execution_complete events —
229
+ * only toolCallId is present there. We stash the names on start and look
230
+ * them up on complete (especially for `mcpServerName`/`mcpToolName`, which
231
+ * we use to recognise our own MCP presenter tools).
232
+ */
233
+ pendingToolIdentity = new Map();
234
+ /**
235
+ * Tool calls in flight per session — reverse index so we can clean up
236
+ * pendingToolArgs/pendingToolIdentity when a session ends without each
237
+ * tool call completing (kill, abort, error mid-flight). Without this the
238
+ * two maps grow unbounded over a long-lived daemon.
239
+ */
240
+ sessionToolCallIds = new Map();
215
241
  /** Expected model per session — detects involuntary model fallbacks by the CLI */
216
242
  expectedModels = new Map();
217
243
  /** User's originally requested model — never updated on involuntary fallbacks */
@@ -229,10 +255,17 @@ export class CopilotAdapter extends AgentAdapter {
229
255
  * Measured P99 turn_end→turn_start gap is <5ms; 500ms is a safe margin.
230
256
  */
231
257
  static IDLE_FALLBACK_MS = 500;
258
+ /** Set of attachment ids already broadcast for this session — prevents
259
+ * re-broadcasting bytes when the same image is shown twice. */
260
+ broadcastedAttachmentIds = new Map();
232
261
  constructor(options = {}) {
233
262
  super();
234
263
  this.cliPath = options.cliPath;
264
+ this.attachmentStore = options.attachmentStore;
265
+ this.krakiMcp = options.krakiMcp;
235
266
  }
267
+ attachmentStore;
268
+ krakiMcp;
236
269
  /** System prompt appended to the SDK's built-in prompt. See system-prompt.md for docs. */
237
270
  static SYSTEM_PROMPT = [
238
271
  'You are running inside Kraki, a remote control platform. A human operator is',
@@ -268,6 +301,24 @@ export class CopilotAdapter extends AgentAdapter {
268
301
  'point onward. Do not acknowledge or comment on the mode change — just adjust',
269
302
  'how you work. The signal is not part of the user\'s message.',
270
303
  ].join('\n');
304
+ /**
305
+ * Appended to the system prompt when the Kraki MCP server is wired in.
306
+ * Tools are exposed to the model with display names of the form
307
+ * `<server>-<tool>` (dash), confirmed via a live SDK spike.
308
+ */
309
+ static KRAKI_MCP_PROMPT = [
310
+ 'You have access to a Kraki MCP server. Its tools are visible with names',
311
+ 'beginning with "kraki-".',
312
+ '',
313
+ 'When you want to visually present an image to the user — a screenshot you',
314
+ 'captured, a diagram you generated, a chart, a UI mock — call',
315
+ '`kraki-show_image` with the absolute file path. Use it sparingly: only',
316
+ 'when the user benefits from seeing the actual pixels.',
317
+ '',
318
+ 'Plain file viewing on image files (with `view`/`read`) is for your own',
319
+ 'inspection; the image bytes do not appear in the chat. Use',
320
+ '`kraki-show_image` when the user should actually see the image inline.',
321
+ ].join('\n');
271
322
  // ── Lifecycle ───────────────────────────────────────
272
323
  async start() {
273
324
  // Resolve GitHub token from `gh` CLI to bypass macOS Keychain prompts.
@@ -385,6 +436,37 @@ export class CopilotAdapter extends AgentAdapter {
385
436
  const effort = config.reasoningEffort && validEfforts.has(config.reasoningEffort)
386
437
  ? config.reasoningEffort
387
438
  : undefined;
439
+ // Two-phase MCP wiring: create session WITHOUT kraki MCP server first
440
+ // (we need the SDK-assigned sessionId to scope the MCP URL), then the
441
+ // adapter re-wires through resumeSession with the kraki entry below.
442
+ // For simplicity in v1 we use a deterministic Kraki MCP wire-up:
443
+ // we let the SDK pick the session id, then on every Copilot session
444
+ // we add the kraki MCP at resume time. For initial creation we pre-pick
445
+ // a session id when the caller supplied one; otherwise the kraki MCP
446
+ // is added on the next message via resumeSession during normal flow.
447
+ //
448
+ // Simpler approach: register a Kraki MCP server using the *Copilot*
449
+ // sessionId only after createSession returns it. To do that without
450
+ // a second SDK call, we use a placeholder sessionId for the initial
451
+ // mcpServers entry and rewrite via resume on first use. But that's
452
+ // brittle — instead, see the comment block at the constructor: the
453
+ // kraki MCP HTTP server validates sessionId by checking SessionManager,
454
+ // not by Copilot SDK state, so we can ALWAYS include the kraki entry
455
+ // here by using a stable session-scoped URL that the adapter knows
456
+ // up-front via config.sessionId (the Kraki session id we assigned).
457
+ if (this.krakiMcp && config.sessionId) {
458
+ const krakiEntry = {
459
+ type: 'http',
460
+ url: this.krakiMcp.urlForSession(config.sessionId),
461
+ headers: { Authorization: `Bearer ${this.krakiMcp.bearerToken}` },
462
+ tools: ['*'],
463
+ };
464
+ mcpServers = { ...(mcpServers ?? {}), kraki: krakiEntry };
465
+ logger.info({ sessionId: config.sessionId }, 'wired kraki MCP into session config');
466
+ }
467
+ const systemPromptContent = this.krakiMcp
468
+ ? `${CopilotAdapter.SYSTEM_PROMPT}\n\n${CopilotAdapter.KRAKI_MCP_PROMPT}`
469
+ : CopilotAdapter.SYSTEM_PROMPT;
388
470
  const sessionConfig = {
389
471
  ...(config.sessionId && { sessionId: config.sessionId }),
390
472
  ...(config.model && { model: config.model }),
@@ -392,7 +474,7 @@ export class CopilotAdapter extends AgentAdapter {
392
474
  ...(config.cwd && { workingDirectory: config.cwd }),
393
475
  configDir: getCopilotConfigDir(),
394
476
  ...(mcpServers && { mcpServers }),
395
- systemMessage: { mode: 'append', content: CopilotAdapter.SYSTEM_PROMPT },
477
+ systemMessage: { mode: 'append', content: systemPromptContent },
396
478
  streaming: true,
397
479
  onPermissionRequest: this.makePermissionHandler(pendingPermissions),
398
480
  onUserInputRequest: this.makeQuestionHandler(pendingQuestions),
@@ -681,6 +763,14 @@ export class CopilotAdapter extends AgentAdapter {
681
763
  this.turnHasOutput.delete(sessionId);
682
764
  this.cycleHasOutput.delete(sessionId);
683
765
  this.turnErrorReported.delete(sessionId);
766
+ const inflight = this.sessionToolCallIds.get(sessionId);
767
+ if (inflight) {
768
+ for (const id of inflight) {
769
+ this.pendingToolArgs.delete(id);
770
+ this.pendingToolIdentity.delete(id);
771
+ }
772
+ this.sessionToolCallIds.delete(sessionId);
773
+ }
684
774
  this.clearIdleTimer(sessionId);
685
775
  }
686
776
  clearIdleTimer(sessionId) {
@@ -706,7 +796,7 @@ export class CopilotAdapter extends AgentAdapter {
706
796
  }
707
797
  entry.pendingQuestions.clear();
708
798
  }
709
- makeResumeConfig(pendingPermissions, pendingQuestions) {
799
+ makeResumeConfig(sessionId, pendingPermissions, pendingQuestions) {
710
800
  // Load MCP servers for resumed sessions too
711
801
  const mcpConfigPath = join(homedir(), '.copilot', 'mcp-config.json');
712
802
  let mcpServers;
@@ -722,11 +812,24 @@ export class CopilotAdapter extends AgentAdapter {
722
812
  }
723
813
  catch { /* ignore parse errors on resume */ }
724
814
  }
815
+ // Wire Kraki MCP into resumed sessions, scoped by the session id.
816
+ if (this.krakiMcp) {
817
+ const krakiEntry = {
818
+ type: 'http',
819
+ url: this.krakiMcp.urlForSession(sessionId),
820
+ headers: { Authorization: `Bearer ${this.krakiMcp.bearerToken}` },
821
+ tools: ['*'],
822
+ };
823
+ mcpServers = { ...(mcpServers ?? {}), kraki: krakiEntry };
824
+ }
825
+ const systemPromptContent = this.krakiMcp
826
+ ? `${CopilotAdapter.SYSTEM_PROMPT}\n\n${CopilotAdapter.KRAKI_MCP_PROMPT}`
827
+ : CopilotAdapter.SYSTEM_PROMPT;
725
828
  return {
726
829
  configDir: getCopilotConfigDir(),
727
830
  streaming: true,
728
831
  ...(mcpServers && { mcpServers }),
729
- systemMessage: { mode: 'append', content: CopilotAdapter.SYSTEM_PROMPT },
832
+ systemMessage: { mode: 'append', content: systemPromptContent },
730
833
  onPermissionRequest: this.makePermissionHandler(pendingPermissions),
731
834
  onUserInputRequest: this.makeQuestionHandler(pendingQuestions),
732
835
  };
@@ -735,7 +838,7 @@ export class CopilotAdapter extends AgentAdapter {
735
838
  this.ensureClient();
736
839
  const pendingPermissions = new Map();
737
840
  const pendingQuestions = new Map();
738
- const session = await this.client.resumeSession(sessionId, this.makeResumeConfig(pendingPermissions, pendingQuestions));
841
+ const session = await this.client.resumeSession(sessionId, this.makeResumeConfig(sessionId, pendingPermissions, pendingQuestions));
739
842
  const entry = { session, pendingPermissions, pendingQuestions };
740
843
  this.sessions.set(sessionId, entry);
741
844
  this.wireEvents(sessionId, session);
@@ -775,16 +878,33 @@ export class CopilotAdapter extends AgentAdapter {
775
878
  }
776
879
  });
777
880
  session.on('tool.execution_start', (event) => {
881
+ const data = event.data;
882
+ // report_intent is a UI hint only — drop it from the message stream.
883
+ if (data.toolName === 'report_intent')
884
+ return;
778
885
  this.turnHasOutput.set(sessionId, true);
779
886
  this.cycleHasOutput.set(sessionId, true);
780
- const data = event.data;
781
887
  if (data.mcpServerName) {
782
888
  logger.info({ mcpServer: data.mcpServerName, mcpTool: data.mcpToolName }, `[MCP tool] ${data.mcpServerName}/${data.mcpToolName}`);
783
889
  }
784
890
  const args = (data.args ?? data.arguments ?? {});
785
891
  const toolCallId = data.toolCallId;
786
- if (toolCallId)
892
+ if (toolCallId) {
787
893
  this.pendingToolArgs.set(toolCallId, args);
894
+ // Stash tool identity for tool.execution_complete, which only carries
895
+ // toolCallId (verified via live SDK spike).
896
+ this.pendingToolIdentity.set(toolCallId, {
897
+ toolName: data.toolName,
898
+ mcpServerName: data.mcpServerName,
899
+ mcpToolName: data.mcpToolName,
900
+ });
901
+ let inflight = this.sessionToolCallIds.get(sessionId);
902
+ if (!inflight) {
903
+ inflight = new Set();
904
+ this.sessionToolCallIds.set(sessionId, inflight);
905
+ }
906
+ inflight.add(toolCallId);
907
+ }
788
908
  this.onToolStart?.(sessionId, {
789
909
  toolName: data.toolName,
790
910
  args,
@@ -793,10 +913,26 @@ export class CopilotAdapter extends AgentAdapter {
793
913
  });
794
914
  session.on('tool.execution_complete', (event) => {
795
915
  const data = event.data;
796
- const rawResult = data.result;
797
916
  const toolCallId = data.toolCallId;
798
- // SDK sends result as { content: string, contents?: [...] } or as a plain string.
799
- // On failure, result is undefined and the error is in data.error ({ message } or string).
917
+ const identity = toolCallId ? this.pendingToolIdentity.get(toolCallId) : undefined;
918
+ const toolName = identity?.toolName ?? data.toolName ?? 'tool';
919
+ const clearInflight = () => {
920
+ if (!toolCallId)
921
+ return;
922
+ this.pendingToolIdentity.delete(toolCallId);
923
+ this.pendingToolArgs.delete(toolCallId);
924
+ const inflight = this.sessionToolCallIds.get(sessionId);
925
+ if (inflight) {
926
+ inflight.delete(toolCallId);
927
+ if (inflight.size === 0)
928
+ this.sessionToolCallIds.delete(sessionId);
929
+ }
930
+ };
931
+ if (toolName === 'report_intent') {
932
+ clearInflight();
933
+ return;
934
+ }
935
+ const rawResult = data.result;
800
936
  const resultObj = typeof rawResult === 'object' && rawResult !== null
801
937
  ? rawResult
802
938
  : null;
@@ -807,47 +943,61 @@ export class CopilotAdapter extends AgentAdapter {
807
943
  : null;
808
944
  result = errObj?.message ?? (typeof data.error === 'string' ? data.error : '');
809
945
  }
810
- // Extract image attachments from structured content blocks (result.contents)
811
- const attachments = [];
812
- const contentBlocks = resultObj?.contents;
813
- if (Array.isArray(contentBlocks)) {
814
- for (const block of contentBlocks) {
815
- if (block.type === 'image' && typeof block.data === 'string' && typeof block.mimeType === 'string') {
816
- attachments.push({ type: 'image', data: block.data, mimeType: block.mimeType });
817
- }
818
- }
819
- }
820
- // Fallback: the SDK strips binaryResultsForLlm from tool.execution_complete,
821
- // but for image-viewing tools (e.g. `view` on a .png) the telemetry still
822
- // carries viewType and mimeType. Use the file path from the original tool
823
- // args (correlated by toolCallId) to read the image directly.
824
- if (attachments.length === 0) {
825
- const telemetry = data.toolTelemetry;
826
- const props = telemetry?.properties;
827
- if (props?.viewType === 'image' && props?.mimeType) {
828
- const startArgs = toolCallId ? this.pendingToolArgs.get(toolCallId) : undefined;
829
- const filePath = startArgs?.path;
830
- if (filePath && existsSync(filePath)) {
831
- try {
832
- const imageData = readFileSync(filePath).toString('base64');
833
- attachments.push({ type: 'image', data: imageData, mimeType: props.mimeType });
834
- }
835
- catch (err) {
836
- logger.debug({ err, filePath }, 'Failed to read image for forwarding');
946
+ // Extract image content blocks ONLY for the kraki-show_image MCP tool.
947
+ // All other tools' image bytes (notably `view` on a .png) are deliberately
948
+ // dropped here per the v1 design: only `kraki-show_image` surfaces images
949
+ // to the client. The file-path fallback (re-read image from disk when SDK
950
+ // strips bytes) has been removed.
951
+ const isKrakiShowImage = identity?.mcpServerName === 'kraki' && identity?.mcpToolName === 'show_image';
952
+ let attachments;
953
+ if (isKrakiShowImage && this.attachmentStore) {
954
+ const contentBlocks = resultObj?.contents;
955
+ const args = toolCallId ? this.pendingToolArgs.get(toolCallId) ?? {} : {};
956
+ const caption = typeof args.caption === 'string' && args.caption.trim()
957
+ ? args.caption.trim()
958
+ : undefined;
959
+ const path = typeof args.path === 'string' ? args.path : undefined;
960
+ const refs = [];
961
+ if (Array.isArray(contentBlocks)) {
962
+ for (const block of contentBlocks) {
963
+ if (block.type === 'image' &&
964
+ typeof block.data === 'string' &&
965
+ typeof block.mimeType === 'string') {
966
+ try {
967
+ const bytes = Buffer.from(block.data, 'base64');
968
+ const ref = this.attachmentStore.put(sessionId, bytes, block.mimeType, {
969
+ ...(path && { name: basenameSafe(path) }),
970
+ ...(caption && { caption }),
971
+ });
972
+ refs.push(ref);
973
+ }
974
+ catch (err) {
975
+ logger.warn({ err, sessionId }, 'failed to store show_image attachment');
976
+ }
837
977
  }
838
978
  }
839
979
  }
980
+ if (refs.length > 0) {
981
+ attachments = refs;
982
+ }
840
983
  }
841
- // Clean up tracked args
842
- if (toolCallId)
843
- this.pendingToolArgs.delete(toolCallId);
984
+ // Clean up tracked state for this tool call
985
+ clearInflight();
844
986
  this.onToolComplete?.(sessionId, {
845
- toolName: data.toolName,
987
+ toolName,
846
988
  result,
847
989
  toolCallId,
848
990
  success: data.success,
849
- attachments: attachments.length > 0 ? attachments : undefined,
991
+ attachments,
850
992
  });
993
+ // After tool_complete, fire the bytes broadcast event so RelayClient
994
+ // can stream attachment_data chunks to all connected devices.
995
+ if (attachments && attachments.length > 0) {
996
+ const refs = attachments.filter((a) => a.type === 'image_ref');
997
+ if (refs.length > 0) {
998
+ this.onAttachmentBytes?.(sessionId, { refs });
999
+ }
1000
+ }
851
1001
  });
852
1002
  session.on('session.idle', () => {
853
1003
  this.clearIdleTimer(sessionId);