@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 +2 -0
- package/dist/adapters/base.d.ts +9 -0
- package/dist/adapters/base.js +3 -0
- package/dist/adapters/base.js.map +1 -1
- package/dist/adapters/copilot.d.ts +36 -0
- package/dist/adapters/copilot.js +206 -60
- package/dist/adapters/copilot.js.map +1 -1
- package/dist/attachment-store.d.ts +73 -0
- package/dist/attachment-store.js +317 -0
- package/dist/attachment-store.js.map +1 -0
- package/dist/daemon-worker.js +34 -2
- package/dist/daemon-worker.js.map +1 -1
- package/dist/mcp/index.d.ts +7 -0
- package/dist/mcp/index.js +5 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/protocol.d.ts +82 -0
- package/dist/mcp/protocol.js +19 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.d.ts +58 -0
- package/dist/mcp/server.js +253 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools/index.d.ts +24 -0
- package/dist/mcp/tools/index.js +19 -0
- package/dist/mcp/tools/index.js.map +1 -0
- package/dist/mcp/tools/show-image.d.ts +10 -0
- package/dist/mcp/tools/show-image.js +98 -0
- package/dist/mcp/tools/show-image.js.map +1 -0
- package/dist/relay-client.d.ts +36 -1
- package/dist/relay-client.js +183 -5
- package/dist/relay-client.js.map +1 -1
- package/dist/session-manager.d.ts +31 -0
- package/dist/session-manager.js +121 -0
- package/dist/session-manager.js.map +1 -1
- package/dist/update.js +59 -7
- package/dist/update.js.map +1 -1
- package/package.json +2 -2
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
|
|
package/dist/adapters/base.d.ts
CHANGED
|
@@ -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;
|
package/dist/adapters/base.js
CHANGED
|
@@ -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;
|
|
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<{
|
package/dist/adapters/copilot.js
CHANGED
|
@@ -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
|
|
247
|
-
' current session. Explain what you intend to do before
|
|
248
|
-
' operator can decide.',
|
|
249
|
-
'- **discuss**: Read operations
|
|
250
|
-
' auto-denied (
|
|
251
|
-
'
|
|
252
|
-
'
|
|
253
|
-
'
|
|
254
|
-
'
|
|
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
|
|
259
|
-
' auto-answered
|
|
260
|
-
'
|
|
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,
|
|
263
|
-
'message will
|
|
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
|
-
'
|
|
268
|
-
'point onward
|
|
269
|
-
'
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
|
847
|
-
|
|
848
|
-
this.pendingToolArgs.delete(toolCallId);
|
|
985
|
+
// Clean up tracked state for this tool call
|
|
986
|
+
clearInflight();
|
|
849
987
|
this.onToolComplete?.(sessionId, {
|
|
850
|
-
toolName
|
|
988
|
+
toolName,
|
|
851
989
|
result,
|
|
852
990
|
toolCallId,
|
|
853
991
|
success: data.success,
|
|
854
|
-
attachments
|
|
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);
|