@kraki/tentacle 0.15.4 → 0.16.1

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',
@@ -240,33 +273,52 @@ export class CopilotAdapter extends AgentAdapter {
240
273
  'encrypted relay. Your tool calls are routed through a permission system that',
241
274
  'approves, denies, or prompts the operator depending on the current mode.',
242
275
  '',
243
- 'There are four permission modes. Sessions start in discuss mode by default.',
276
+ 'There are four permission modes. **Sessions start in `discuss` mode by default.**',
244
277
  '',
245
278
  '- **safe**: Every tool call requires explicit operator approval, unless the',
246
- ' operator has previously clicked "Always Allow" for that tool kind in the',
247
- ' current session. Explain what you intend to do before each action so the',
248
- ' operator can decide.',
249
- '- **discuss**: Read operations are auto-approved. Write operations are',
250
- ' auto-denied (returns denial feedback); except `plan.md` (auto-approve).',
251
- ' Discuss proposed changes before attempting writes.',
252
- ' Do not use shell commands (sed, tee, echo >, scripts, etc.) to modify',
253
- ' files use the edit/create tools instead.',
254
- ' Shell commands, web fetches, and MCP tools are all allowed in discuss mode.',
279
+ ' operator has previously clicked "Always Allow" for that tool kind (shell,',
280
+ ' write, etc.) in the current session. Explain what you intend to do before',
281
+ ' each action so the operator can decide.',
282
+ '- **discuss**: Read operations, shell commands, web fetches, and MCP tools',
283
+ ' are auto-approved. Write operations are auto-denied (the tool returns a',
284
+ ' rejection feedback), with one exception: writes to a file named `plan.md`',
285
+ ' (in any directory) are auto-approved. Discuss proposed changes before',
286
+ ' attempting writes. Do not use shell (sed, tee, echo >, scripts, etc.) to',
287
+ ' modify files use the edit/create tools instead, which respect the mode.',
255
288
  '- **execute**: All tool calls are auto-approved. Be efficient and execute',
256
289
  ' directly without asking for confirmation. If unsure about intent or',
257
290
  ' approach, ask the operator for clarification before proceeding.',
258
- '- **delegate**: All tool calls are auto-approved and questions are',
259
- ' auto-answered on your behalf. Work fully autonomously do not expect',
260
- ' interactive input.',
291
+ '- **delegate**: All tool calls are auto-approved. Questions you ask via',
292
+ ' `ask_user` are auto-answered with `"proceed with your best judgment"` —',
293
+ ' do not re-ask; just make a reasonable call and continue.',
261
294
  '',
262
- 'The operator may switch modes during the session. When this happens, your next',
263
- 'message will begin with a mode switch signal in this format:',
295
+ 'The operator may switch modes during the session. When this happens, the',
296
+ 'next user message you receive will be prefixed with a signal in this format:',
264
297
  '',
265
298
  ' [kraki: mode changed to <mode>]',
266
299
  '',
267
- 'When you see this signal, silently adopt the new mode\'s behavior from that',
268
- 'point onward. Do not acknowledge or comment on the mode change — just adjust',
269
- 'how you work. The signal is not part of the user\'s message.',
300
+ 'Treat the signal as out-of-band metadata: silently adopt the new mode\'s',
301
+ 'behavior from that point onward, do not acknowledge or comment on the mode',
302
+ 'change, and do not quote the signal back. The text after the signal is the',
303
+ 'real user message.',
304
+ ].join('\n');
305
+ /**
306
+ * Appended to the system prompt when the Kraki MCP server is wired in.
307
+ * Tools are exposed to the model with display names of the form
308
+ * `<server>-<tool>` (dash), confirmed via a live SDK spike.
309
+ */
310
+ static KRAKI_MCP_PROMPT = [
311
+ 'You have access to a Kraki MCP server. Its tools are visible with names',
312
+ 'beginning with "kraki-".',
313
+ '',
314
+ 'When you want to visually present an image to the user — a screenshot you',
315
+ 'captured, a diagram you generated, a chart, a UI mock — call',
316
+ '`kraki-show_image` with the absolute file path. Use it sparingly: only',
317
+ 'when the user benefits from seeing the actual pixels.',
318
+ '',
319
+ 'Plain file viewing on image files (with `view`/`read`) is for your own',
320
+ 'inspection — you see the image bytes, but they are not shown to the user.',
321
+ 'Use `kraki-show_image` when the user should actually see the image inline.',
270
322
  ].join('\n');
271
323
  // ── Lifecycle ───────────────────────────────────────
272
324
  async start() {
@@ -385,6 +437,37 @@ export class CopilotAdapter extends AgentAdapter {
385
437
  const effort = config.reasoningEffort && validEfforts.has(config.reasoningEffort)
386
438
  ? config.reasoningEffort
387
439
  : undefined;
440
+ // Two-phase MCP wiring: create session WITHOUT kraki MCP server first
441
+ // (we need the SDK-assigned sessionId to scope the MCP URL), then the
442
+ // adapter re-wires through resumeSession with the kraki entry below.
443
+ // For simplicity in v1 we use a deterministic Kraki MCP wire-up:
444
+ // we let the SDK pick the session id, then on every Copilot session
445
+ // we add the kraki MCP at resume time. For initial creation we pre-pick
446
+ // a session id when the caller supplied one; otherwise the kraki MCP
447
+ // is added on the next message via resumeSession during normal flow.
448
+ //
449
+ // Simpler approach: register a Kraki MCP server using the *Copilot*
450
+ // sessionId only after createSession returns it. To do that without
451
+ // a second SDK call, we use a placeholder sessionId for the initial
452
+ // mcpServers entry and rewrite via resume on first use. But that's
453
+ // brittle — instead, see the comment block at the constructor: the
454
+ // kraki MCP HTTP server validates sessionId by checking SessionManager,
455
+ // not by Copilot SDK state, so we can ALWAYS include the kraki entry
456
+ // here by using a stable session-scoped URL that the adapter knows
457
+ // up-front via config.sessionId (the Kraki session id we assigned).
458
+ if (this.krakiMcp && config.sessionId) {
459
+ const krakiEntry = {
460
+ type: 'http',
461
+ url: this.krakiMcp.urlForSession(config.sessionId),
462
+ headers: { Authorization: `Bearer ${this.krakiMcp.bearerToken}` },
463
+ tools: ['*'],
464
+ };
465
+ mcpServers = { ...(mcpServers ?? {}), kraki: krakiEntry };
466
+ logger.info({ sessionId: config.sessionId }, 'wired kraki MCP into session config');
467
+ }
468
+ const systemPromptContent = this.krakiMcp
469
+ ? `${CopilotAdapter.SYSTEM_PROMPT}\n\n${CopilotAdapter.KRAKI_MCP_PROMPT}`
470
+ : CopilotAdapter.SYSTEM_PROMPT;
388
471
  const sessionConfig = {
389
472
  ...(config.sessionId && { sessionId: config.sessionId }),
390
473
  ...(config.model && { model: config.model }),
@@ -392,7 +475,7 @@ export class CopilotAdapter extends AgentAdapter {
392
475
  ...(config.cwd && { workingDirectory: config.cwd }),
393
476
  configDir: getCopilotConfigDir(),
394
477
  ...(mcpServers && { mcpServers }),
395
- systemMessage: { mode: 'append', content: CopilotAdapter.SYSTEM_PROMPT },
478
+ systemMessage: { mode: 'append', content: systemPromptContent },
396
479
  streaming: true,
397
480
  onPermissionRequest: this.makePermissionHandler(pendingPermissions),
398
481
  onUserInputRequest: this.makeQuestionHandler(pendingQuestions),
@@ -681,6 +764,14 @@ export class CopilotAdapter extends AgentAdapter {
681
764
  this.turnHasOutput.delete(sessionId);
682
765
  this.cycleHasOutput.delete(sessionId);
683
766
  this.turnErrorReported.delete(sessionId);
767
+ const inflight = this.sessionToolCallIds.get(sessionId);
768
+ if (inflight) {
769
+ for (const id of inflight) {
770
+ this.pendingToolArgs.delete(id);
771
+ this.pendingToolIdentity.delete(id);
772
+ }
773
+ this.sessionToolCallIds.delete(sessionId);
774
+ }
684
775
  this.clearIdleTimer(sessionId);
685
776
  }
686
777
  clearIdleTimer(sessionId) {
@@ -706,7 +797,7 @@ export class CopilotAdapter extends AgentAdapter {
706
797
  }
707
798
  entry.pendingQuestions.clear();
708
799
  }
709
- makeResumeConfig(pendingPermissions, pendingQuestions) {
800
+ makeResumeConfig(sessionId, pendingPermissions, pendingQuestions) {
710
801
  // Load MCP servers for resumed sessions too
711
802
  const mcpConfigPath = join(homedir(), '.copilot', 'mcp-config.json');
712
803
  let mcpServers;
@@ -722,11 +813,24 @@ export class CopilotAdapter extends AgentAdapter {
722
813
  }
723
814
  catch { /* ignore parse errors on resume */ }
724
815
  }
816
+ // Wire Kraki MCP into resumed sessions, scoped by the session id.
817
+ if (this.krakiMcp) {
818
+ const krakiEntry = {
819
+ type: 'http',
820
+ url: this.krakiMcp.urlForSession(sessionId),
821
+ headers: { Authorization: `Bearer ${this.krakiMcp.bearerToken}` },
822
+ tools: ['*'],
823
+ };
824
+ mcpServers = { ...(mcpServers ?? {}), kraki: krakiEntry };
825
+ }
826
+ const systemPromptContent = this.krakiMcp
827
+ ? `${CopilotAdapter.SYSTEM_PROMPT}\n\n${CopilotAdapter.KRAKI_MCP_PROMPT}`
828
+ : CopilotAdapter.SYSTEM_PROMPT;
725
829
  return {
726
830
  configDir: getCopilotConfigDir(),
727
831
  streaming: true,
728
832
  ...(mcpServers && { mcpServers }),
729
- systemMessage: { mode: 'append', content: CopilotAdapter.SYSTEM_PROMPT },
833
+ systemMessage: { mode: 'append', content: systemPromptContent },
730
834
  onPermissionRequest: this.makePermissionHandler(pendingPermissions),
731
835
  onUserInputRequest: this.makeQuestionHandler(pendingQuestions),
732
836
  };
@@ -735,7 +839,7 @@ export class CopilotAdapter extends AgentAdapter {
735
839
  this.ensureClient();
736
840
  const pendingPermissions = new Map();
737
841
  const pendingQuestions = new Map();
738
- const session = await this.client.resumeSession(sessionId, this.makeResumeConfig(pendingPermissions, pendingQuestions));
842
+ const session = await this.client.resumeSession(sessionId, this.makeResumeConfig(sessionId, pendingPermissions, pendingQuestions));
739
843
  const entry = { session, pendingPermissions, pendingQuestions };
740
844
  this.sessions.set(sessionId, entry);
741
845
  this.wireEvents(sessionId, session);
@@ -786,8 +890,22 @@ export class CopilotAdapter extends AgentAdapter {
786
890
  }
787
891
  const args = (data.args ?? data.arguments ?? {});
788
892
  const toolCallId = data.toolCallId;
789
- if (toolCallId)
893
+ if (toolCallId) {
790
894
  this.pendingToolArgs.set(toolCallId, args);
895
+ // Stash tool identity for tool.execution_complete, which only carries
896
+ // toolCallId (verified via live SDK spike).
897
+ this.pendingToolIdentity.set(toolCallId, {
898
+ toolName: data.toolName,
899
+ mcpServerName: data.mcpServerName,
900
+ mcpToolName: data.mcpToolName,
901
+ });
902
+ let inflight = this.sessionToolCallIds.get(sessionId);
903
+ if (!inflight) {
904
+ inflight = new Set();
905
+ this.sessionToolCallIds.set(sessionId, inflight);
906
+ }
907
+ inflight.add(toolCallId);
908
+ }
791
909
  this.onToolStart?.(sessionId, {
792
910
  toolName: data.toolName,
793
911
  args,
@@ -796,12 +914,26 @@ export class CopilotAdapter extends AgentAdapter {
796
914
  });
797
915
  session.on('tool.execution_complete', (event) => {
798
916
  const data = event.data;
799
- if (data.toolName === 'report_intent')
917
+ const toolCallId = data.toolCallId;
918
+ const identity = toolCallId ? this.pendingToolIdentity.get(toolCallId) : undefined;
919
+ const toolName = identity?.toolName ?? data.toolName ?? 'tool';
920
+ const clearInflight = () => {
921
+ if (!toolCallId)
922
+ return;
923
+ this.pendingToolIdentity.delete(toolCallId);
924
+ this.pendingToolArgs.delete(toolCallId);
925
+ const inflight = this.sessionToolCallIds.get(sessionId);
926
+ if (inflight) {
927
+ inflight.delete(toolCallId);
928
+ if (inflight.size === 0)
929
+ this.sessionToolCallIds.delete(sessionId);
930
+ }
931
+ };
932
+ if (toolName === 'report_intent') {
933
+ clearInflight();
800
934
  return;
935
+ }
801
936
  const rawResult = data.result;
802
- const toolCallId = data.toolCallId;
803
- // SDK sends result as { content: string, contents?: [...] } or as a plain string.
804
- // On failure, result is undefined and the error is in data.error ({ message } or string).
805
937
  const resultObj = typeof rawResult === 'object' && rawResult !== null
806
938
  ? rawResult
807
939
  : null;
@@ -812,47 +944,61 @@ export class CopilotAdapter extends AgentAdapter {
812
944
  : null;
813
945
  result = errObj?.message ?? (typeof data.error === 'string' ? data.error : '');
814
946
  }
815
- // Extract image attachments from structured content blocks (result.contents)
816
- const attachments = [];
817
- const contentBlocks = resultObj?.contents;
818
- if (Array.isArray(contentBlocks)) {
819
- for (const block of contentBlocks) {
820
- if (block.type === 'image' && typeof block.data === 'string' && typeof block.mimeType === 'string') {
821
- attachments.push({ type: 'image', data: block.data, mimeType: block.mimeType });
822
- }
823
- }
824
- }
825
- // Fallback: the SDK strips binaryResultsForLlm from tool.execution_complete,
826
- // but for image-viewing tools (e.g. `view` on a .png) the telemetry still
827
- // carries viewType and mimeType. Use the file path from the original tool
828
- // args (correlated by toolCallId) to read the image directly.
829
- if (attachments.length === 0) {
830
- const telemetry = data.toolTelemetry;
831
- const props = telemetry?.properties;
832
- if (props?.viewType === 'image' && props?.mimeType) {
833
- const startArgs = toolCallId ? this.pendingToolArgs.get(toolCallId) : undefined;
834
- const filePath = startArgs?.path;
835
- if (filePath && existsSync(filePath)) {
836
- try {
837
- const imageData = readFileSync(filePath).toString('base64');
838
- attachments.push({ type: 'image', data: imageData, mimeType: props.mimeType });
839
- }
840
- catch (err) {
841
- logger.debug({ err, filePath }, 'Failed to read image for forwarding');
947
+ // Extract image content blocks ONLY for the kraki-show_image MCP tool.
948
+ // All other tools' image bytes (notably `view` on a .png) are deliberately
949
+ // dropped here per the v1 design: only `kraki-show_image` surfaces images
950
+ // to the client. The file-path fallback (re-read image from disk when SDK
951
+ // strips bytes) has been removed.
952
+ const isKrakiShowImage = identity?.mcpServerName === 'kraki' && identity?.mcpToolName === 'show_image';
953
+ let attachments;
954
+ if (isKrakiShowImage && this.attachmentStore) {
955
+ const contentBlocks = resultObj?.contents;
956
+ const args = toolCallId ? this.pendingToolArgs.get(toolCallId) ?? {} : {};
957
+ const caption = typeof args.caption === 'string' && args.caption.trim()
958
+ ? args.caption.trim()
959
+ : undefined;
960
+ const path = typeof args.path === 'string' ? args.path : undefined;
961
+ const refs = [];
962
+ if (Array.isArray(contentBlocks)) {
963
+ for (const block of contentBlocks) {
964
+ if (block.type === 'image' &&
965
+ typeof block.data === 'string' &&
966
+ typeof block.mimeType === 'string') {
967
+ try {
968
+ const bytes = Buffer.from(block.data, 'base64');
969
+ const ref = this.attachmentStore.put(sessionId, bytes, block.mimeType, {
970
+ ...(path && { name: basenameSafe(path) }),
971
+ ...(caption && { caption }),
972
+ });
973
+ refs.push(ref);
974
+ }
975
+ catch (err) {
976
+ logger.warn({ err, sessionId }, 'failed to store show_image attachment');
977
+ }
842
978
  }
843
979
  }
844
980
  }
981
+ if (refs.length > 0) {
982
+ attachments = refs;
983
+ }
845
984
  }
846
- // Clean up tracked args
847
- if (toolCallId)
848
- this.pendingToolArgs.delete(toolCallId);
985
+ // Clean up tracked state for this tool call
986
+ clearInflight();
849
987
  this.onToolComplete?.(sessionId, {
850
- toolName: data.toolName,
988
+ toolName,
851
989
  result,
852
990
  toolCallId,
853
991
  success: data.success,
854
- attachments: attachments.length > 0 ? attachments : undefined,
992
+ attachments,
855
993
  });
994
+ // After tool_complete, fire the bytes broadcast event so RelayClient
995
+ // can stream attachment_data chunks to all connected devices.
996
+ if (attachments && attachments.length > 0) {
997
+ const refs = attachments.filter((a) => a.type === 'image_ref');
998
+ if (refs.length > 0) {
999
+ this.onAttachmentBytes?.(sessionId, { refs });
1000
+ }
1001
+ }
856
1002
  });
857
1003
  session.on('session.idle', () => {
858
1004
  this.clearIdleTimer(sessionId);