@canonmsg/claude-code-plugin 0.3.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/.claude-plugin/plugin.json +25 -0
- package/README.md +52 -0
- package/dist/agent-profiles.d.ts +21 -0
- package/dist/agent-profiles.js +63 -0
- package/dist/approval-server.d.ts +23 -0
- package/dist/approval-server.js +133 -0
- package/dist/host.d.ts +2 -0
- package/dist/host.js +813 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.js +85 -0
- package/dist/rtdb-rest.d.ts +34 -0
- package/dist/rtdb-rest.js +128 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +400 -0
- package/dist/setup.d.ts +9 -0
- package/dist/setup.js +66 -0
- package/package.json +53 -0
- package/skills/configure/SKILL.md +50 -0
- package/skills/register/SKILL.md +41 -0
package/dist/host.js
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setDefaultResultOrder } from 'node:dns';
|
|
3
|
+
setDefaultResultOrder('ipv4first');
|
|
4
|
+
/**
|
|
5
|
+
* Canon Agent SDK host wrapper — multi-session.
|
|
6
|
+
*
|
|
7
|
+
* Each Canon conversation gets its own Claude Code session (SDK Query subprocess).
|
|
8
|
+
* Sessions are created lazily on first inbound message and cleaned up after idle timeout.
|
|
9
|
+
*
|
|
10
|
+
* - Inbound Canon messages (SSE) → routed to per-conversation session
|
|
11
|
+
* - Claude responses (SDK iterator) → sendMessage() to the correct conversation
|
|
12
|
+
* - Live streaming → RTDB /streaming/{convoId}/{agentId}
|
|
13
|
+
* - Session state (model, mode, activity) → RTDB /session-state/{convoId}/{agentId}
|
|
14
|
+
* - Control signals (model switch, interrupt) ← RTDB /control/{convoId}/{agentId}
|
|
15
|
+
*
|
|
16
|
+
* Auth: uses forceLoginMethod: 'claudeai' — works with Max/Pro subscription.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* canon-claude # auto-select agent profile
|
|
20
|
+
* canon-claude --cwd /path/to/project # specify working directory
|
|
21
|
+
* CANON_AGENT=mybot canon-claude # use specific profile
|
|
22
|
+
* CANON_API_KEY=agk_... canon-claude # use explicit key
|
|
23
|
+
*/
|
|
24
|
+
import { parseArgs } from 'node:util';
|
|
25
|
+
import { randomUUID } from 'node:crypto';
|
|
26
|
+
import { basename, resolve } from 'node:path';
|
|
27
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
28
|
+
import { CanonClient, CanonStream, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, } from '@canonmsg/core';
|
|
29
|
+
let workingDir = process.cwd();
|
|
30
|
+
let workspaceOptions = [];
|
|
31
|
+
let runtimeModels = [];
|
|
32
|
+
function normalizeString(value) {
|
|
33
|
+
if (typeof value !== 'string')
|
|
34
|
+
return undefined;
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
return trimmed ? trimmed : undefined;
|
|
37
|
+
}
|
|
38
|
+
function buildWorkspaceOptions(primaryCwd, configured) {
|
|
39
|
+
const uniqueDirs = Array.from(new Set([primaryCwd, ...configured].map((dir) => resolve(dir))));
|
|
40
|
+
const seenLabels = new Map();
|
|
41
|
+
return uniqueDirs.map((cwd, index) => {
|
|
42
|
+
const baseLabel = basename(cwd) || cwd;
|
|
43
|
+
const seenCount = (seenLabels.get(baseLabel) ?? 0) + 1;
|
|
44
|
+
seenLabels.set(baseLabel, seenCount);
|
|
45
|
+
return {
|
|
46
|
+
id: index === 0 ? 'default' : `workspace-${index + 1}`,
|
|
47
|
+
label: seenCount === 1 ? baseLabel : `${baseLabel} (${seenCount})`,
|
|
48
|
+
cwd,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function toModelOptions(models) {
|
|
53
|
+
return models.map((model) => ({
|
|
54
|
+
value: model.value,
|
|
55
|
+
label: model.displayName,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
async function publishAgentRuntime(agentId, runtime) {
|
|
59
|
+
await rtdbWrite(`/agent-runtime/${agentId}`, {
|
|
60
|
+
clientType: 'claude-code',
|
|
61
|
+
hostMode: true,
|
|
62
|
+
...runtime,
|
|
63
|
+
updatedAt: { '.sv': 'timestamp' },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async function loadSessionConfig(conversationId, agentId) {
|
|
67
|
+
const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
|
|
68
|
+
if (!raw || typeof raw !== 'object')
|
|
69
|
+
return null;
|
|
70
|
+
const data = raw;
|
|
71
|
+
const permissionMode = normalizeString(data.permissionMode);
|
|
72
|
+
return {
|
|
73
|
+
workspaceId: normalizeString(data.workspaceId),
|
|
74
|
+
legacyCwd: normalizeString(data.cwd),
|
|
75
|
+
model: normalizeString(data.model),
|
|
76
|
+
permissionMode: permissionMode,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function resolveWorkspaceCwd(config) {
|
|
80
|
+
if (config?.workspaceId) {
|
|
81
|
+
const workspace = workspaceOptions.find((option) => option.id === config.workspaceId);
|
|
82
|
+
if (workspace)
|
|
83
|
+
return workspace.cwd;
|
|
84
|
+
}
|
|
85
|
+
if (config?.legacyCwd) {
|
|
86
|
+
const workspace = workspaceOptions.find((option) => option.cwd === config.legacyCwd);
|
|
87
|
+
if (workspace)
|
|
88
|
+
return workspace.cwd;
|
|
89
|
+
}
|
|
90
|
+
return workspaceOptions[0]?.cwd ?? workingDir;
|
|
91
|
+
}
|
|
92
|
+
function toPublicWorkspaceOptions() {
|
|
93
|
+
return workspaceOptions.map(({ id, label }) => ({ id, label }));
|
|
94
|
+
}
|
|
95
|
+
async function detectRuntimeModels(cwd) {
|
|
96
|
+
const emptyPrompt = {
|
|
97
|
+
async *[Symbol.asyncIterator]() { },
|
|
98
|
+
};
|
|
99
|
+
const probe = query({
|
|
100
|
+
prompt: emptyPrompt,
|
|
101
|
+
options: {
|
|
102
|
+
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
103
|
+
tools: { type: 'preset', preset: 'claude_code' },
|
|
104
|
+
permissionMode: 'default',
|
|
105
|
+
cwd,
|
|
106
|
+
settings: { forceLoginMethod: 'claudeai' },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
const init = await probe.initializationResult();
|
|
111
|
+
return toModelOptions(init.models);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
try {
|
|
115
|
+
probe.close();
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ── Constants ───────────────────────────────────────────────────────
|
|
121
|
+
const MAX_SESSIONS = 5;
|
|
122
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
123
|
+
const STREAMING_THROTTLE_MS = 250;
|
|
124
|
+
const HEARTBEAT_MS = 30_000;
|
|
125
|
+
const IDLE_CHECK_MS = 60_000;
|
|
126
|
+
const CONTROL_POLL_MS = 2000;
|
|
127
|
+
function buildCanonPrompt(input) {
|
|
128
|
+
const ownerLine = input.isOwner
|
|
129
|
+
? 'The sender is the verified human owner of this Canon agent.'
|
|
130
|
+
: 'The sender is a Canon user in this conversation.';
|
|
131
|
+
return [
|
|
132
|
+
'You are connected to Canon messaging through a Claude Code host wrapper.',
|
|
133
|
+
'Reply naturally to the Canon user.',
|
|
134
|
+
'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
|
|
135
|
+
'Short intermediate assistant messages may be shown as ephemeral status while you work.',
|
|
136
|
+
ownerLine,
|
|
137
|
+
`Conversation ID: ${input.conversationId}`,
|
|
138
|
+
`Sender: ${input.senderName}`,
|
|
139
|
+
'',
|
|
140
|
+
'New Canon message:',
|
|
141
|
+
input.content,
|
|
142
|
+
].join('\n');
|
|
143
|
+
}
|
|
144
|
+
function renderInboundContent(message) {
|
|
145
|
+
let content = message.text || '';
|
|
146
|
+
if (message.contentType === 'audio' && message.audioUrl) {
|
|
147
|
+
const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
|
|
148
|
+
content = content
|
|
149
|
+
? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
|
|
150
|
+
: `[Voice message${duration}: ${message.audioUrl}]`;
|
|
151
|
+
}
|
|
152
|
+
else if (message.contentType === 'image' && message.imageUrl) {
|
|
153
|
+
content = content
|
|
154
|
+
? `[Image: ${message.imageUrl}]\n${content}`
|
|
155
|
+
: `[Image: ${message.imageUrl}]`;
|
|
156
|
+
}
|
|
157
|
+
return content || '[Empty message]';
|
|
158
|
+
}
|
|
159
|
+
// ── Session factory ─────────────────────────────────────────────────
|
|
160
|
+
function createSession(conversationId, cwd, agentId, client, onSessionEnd, config, resumeSessionId) {
|
|
161
|
+
// Per-session input stream with message queue
|
|
162
|
+
let resolveInput = null;
|
|
163
|
+
let inputDone = false;
|
|
164
|
+
const messageQueue = [];
|
|
165
|
+
const sendInput = (msg) => {
|
|
166
|
+
if (resolveInput) {
|
|
167
|
+
const r = resolveInput;
|
|
168
|
+
resolveInput = null;
|
|
169
|
+
r(msg);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// Queue if iterator isn't waiting yet (e.g., during init)
|
|
173
|
+
messageQueue.push(msg);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const inputStream = {
|
|
177
|
+
[Symbol.asyncIterator]() {
|
|
178
|
+
return {
|
|
179
|
+
next() {
|
|
180
|
+
if (inputDone)
|
|
181
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
182
|
+
// Drain queued messages first (sent before iterator was ready)
|
|
183
|
+
if (messageQueue.length > 0) {
|
|
184
|
+
return Promise.resolve({ done: false, value: messageQueue.shift() });
|
|
185
|
+
}
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
resolveInput = (msg) => resolve({ done: false, value: msg });
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
return() {
|
|
191
|
+
inputDone = true;
|
|
192
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
// Launch Claude Code subprocess
|
|
198
|
+
// eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
|
|
199
|
+
let session;
|
|
200
|
+
const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
201
|
+
function waitForReply() {
|
|
202
|
+
// Keep session alive while waiting for user input
|
|
203
|
+
session.lastActivity = Date.now();
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
session.pendingReply = null;
|
|
207
|
+
reject(new Error('Timed out waiting for user reply'));
|
|
208
|
+
}, REPLY_TIMEOUT_MS);
|
|
209
|
+
session.pendingReply = {
|
|
210
|
+
resolve: (metadata) => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
resolve(metadata);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const queryOptions = {
|
|
218
|
+
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
219
|
+
tools: { type: 'preset', preset: 'claude_code' },
|
|
220
|
+
permissionMode: config.permissionMode,
|
|
221
|
+
cwd,
|
|
222
|
+
includePartialMessages: true,
|
|
223
|
+
maxTurns: 200,
|
|
224
|
+
settings: { forceLoginMethod: 'claudeai' },
|
|
225
|
+
canUseTool: async (toolName, input) => {
|
|
226
|
+
if (toolName === 'AskUserQuestion') {
|
|
227
|
+
try {
|
|
228
|
+
const questionId = randomUUID();
|
|
229
|
+
const questions = input.questions ?? [];
|
|
230
|
+
await client.sendMessage(conversationId, '', {
|
|
231
|
+
metadata: {
|
|
232
|
+
type: 'claude_question',
|
|
233
|
+
questionId,
|
|
234
|
+
questions,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Question sent (${questionId.slice(0, 8)})`);
|
|
238
|
+
const reply = await waitForReply();
|
|
239
|
+
return {
|
|
240
|
+
behavior: 'allow',
|
|
241
|
+
updatedInput: { ...input, answers: reply.answers },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return { behavior: 'deny', message: 'No reply received (timed out)' };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (toolName === 'ExitPlanMode') {
|
|
249
|
+
try {
|
|
250
|
+
const planId = randomUUID();
|
|
251
|
+
await client.sendMessage(conversationId, 'Plan ready for review.', {
|
|
252
|
+
metadata: { type: 'plan_approval', planId },
|
|
253
|
+
});
|
|
254
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
|
|
255
|
+
const reply = await waitForReply();
|
|
256
|
+
if (reply.decision === 'approve') {
|
|
257
|
+
return { behavior: 'allow' };
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
behavior: 'deny',
|
|
261
|
+
message: reply.feedback || 'User requested changes',
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return { behavior: 'deny', message: 'No reply received (timed out)' };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Default: allow (permission mode handles restrictions)
|
|
269
|
+
return { behavior: 'allow' };
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
if (resumeSessionId) {
|
|
273
|
+
queryOptions.resume = resumeSessionId;
|
|
274
|
+
}
|
|
275
|
+
const q = query({
|
|
276
|
+
prompt: inputStream,
|
|
277
|
+
options: queryOptions,
|
|
278
|
+
});
|
|
279
|
+
session = {
|
|
280
|
+
conversationId,
|
|
281
|
+
cwd,
|
|
282
|
+
query: q,
|
|
283
|
+
sendInput,
|
|
284
|
+
state: {
|
|
285
|
+
model: config.model,
|
|
286
|
+
permissionMode: config.permissionMode,
|
|
287
|
+
state: 'idle',
|
|
288
|
+
},
|
|
289
|
+
availableModels: config.availableModels,
|
|
290
|
+
streamingText: '',
|
|
291
|
+
streamingTimer: null,
|
|
292
|
+
seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
|
|
293
|
+
lastActivity: Date.now(),
|
|
294
|
+
closed: false,
|
|
295
|
+
sdkSessionId: resumeSessionId || null,
|
|
296
|
+
pendingReply: null,
|
|
297
|
+
};
|
|
298
|
+
// ── Per-session streaming helpers ──
|
|
299
|
+
function flushStreaming() {
|
|
300
|
+
if (!session.streamingText)
|
|
301
|
+
return;
|
|
302
|
+
rtdbWrite(`/streaming/${conversationId}/${agentId}`, {
|
|
303
|
+
text: session.streamingText,
|
|
304
|
+
status: 'streaming',
|
|
305
|
+
updatedAt: { '.sv': 'timestamp' },
|
|
306
|
+
}).catch(() => { });
|
|
307
|
+
}
|
|
308
|
+
function onStreamDelta(text) {
|
|
309
|
+
session.streamingText += text;
|
|
310
|
+
if (!session.streamingTimer) {
|
|
311
|
+
session.streamingTimer = setTimeout(() => {
|
|
312
|
+
session.streamingTimer = null;
|
|
313
|
+
flushStreaming();
|
|
314
|
+
}, STREAMING_THROTTLE_MS);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function clearStreaming() {
|
|
318
|
+
if (session.streamingTimer) {
|
|
319
|
+
clearTimeout(session.streamingTimer);
|
|
320
|
+
session.streamingTimer = null;
|
|
321
|
+
}
|
|
322
|
+
session.streamingText = '';
|
|
323
|
+
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
324
|
+
}
|
|
325
|
+
function writeState() {
|
|
326
|
+
writeSessionState(conversationId, agentId, {
|
|
327
|
+
...session.state,
|
|
328
|
+
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
329
|
+
cwd,
|
|
330
|
+
hostMode: true,
|
|
331
|
+
isActive: true,
|
|
332
|
+
}).catch(() => { });
|
|
333
|
+
}
|
|
334
|
+
// ── Process SDK output messages ──
|
|
335
|
+
const processMessages = async () => {
|
|
336
|
+
try {
|
|
337
|
+
for await (const msg of q) {
|
|
338
|
+
if (session.closed)
|
|
339
|
+
break;
|
|
340
|
+
switch (msg.type) {
|
|
341
|
+
case 'assistant': {
|
|
342
|
+
// Capture SDK session ID
|
|
343
|
+
const assistantSessionId = msg.session_id;
|
|
344
|
+
if (assistantSessionId)
|
|
345
|
+
session.sdkSessionId = assistantSessionId;
|
|
346
|
+
if (!session.seedResponseHandled) {
|
|
347
|
+
session.seedResponseHandled = true;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
const textBlocks = (msg.message?.content ?? [])
|
|
351
|
+
.filter((b) => b.type === 'text')
|
|
352
|
+
.map((b) => b.text);
|
|
353
|
+
const fullText = textBlocks.join('\n').trim();
|
|
354
|
+
clearStreaming();
|
|
355
|
+
if (fullText) {
|
|
356
|
+
try {
|
|
357
|
+
await client.sendMessage(conversationId, fullText);
|
|
358
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent reply (${fullText.length} chars)`);
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send reply:`, err);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case 'stream_event': {
|
|
367
|
+
const event = msg.event;
|
|
368
|
+
if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
369
|
+
onStreamDelta(event.delta.text);
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case 'system': {
|
|
374
|
+
const subtype = msg.subtype;
|
|
375
|
+
if (subtype === 'session_state_changed') {
|
|
376
|
+
const state = msg.state;
|
|
377
|
+
session.state.state = state;
|
|
378
|
+
if (state === 'running') {
|
|
379
|
+
client.setTyping(conversationId, true, 'thinking').catch(() => { });
|
|
380
|
+
}
|
|
381
|
+
else if (state === 'idle') {
|
|
382
|
+
client.setTyping(conversationId, false).catch(() => { });
|
|
383
|
+
clearStreaming();
|
|
384
|
+
}
|
|
385
|
+
else if (state === 'requires_action') {
|
|
386
|
+
client.setTyping(conversationId, false).catch(() => { });
|
|
387
|
+
}
|
|
388
|
+
writeState();
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case 'result': {
|
|
393
|
+
// Capture SDK session ID for resume support
|
|
394
|
+
const resultSessionId = msg.session_id;
|
|
395
|
+
if (resultSessionId)
|
|
396
|
+
session.sdkSessionId = resultSessionId;
|
|
397
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
|
|
398
|
+
try {
|
|
399
|
+
const usage = await q.getContextUsage();
|
|
400
|
+
writeSessionState(conversationId, agentId, {
|
|
401
|
+
...session.state,
|
|
402
|
+
state: 'idle',
|
|
403
|
+
contextUsage: {
|
|
404
|
+
percentage: usage.percentage,
|
|
405
|
+
totalTokens: usage.totalTokens,
|
|
406
|
+
maxTokens: usage.maxTokens,
|
|
407
|
+
},
|
|
408
|
+
cwd,
|
|
409
|
+
hostMode: true,
|
|
410
|
+
isActive: true,
|
|
411
|
+
}).catch(() => { });
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
// Non-fatal
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
default:
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Query stream error:`, err);
|
|
425
|
+
}
|
|
426
|
+
// Only call onSessionEnd if not already closed (avoids double cleanup from closeSession)
|
|
427
|
+
if (!session.closed) {
|
|
428
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ended`);
|
|
429
|
+
onSessionEnd(conversationId, session.sdkSessionId);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
// Start processing loop and initialize session
|
|
433
|
+
const init = async () => {
|
|
434
|
+
processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
|
|
435
|
+
await q.initializationResult();
|
|
436
|
+
if (config.model) {
|
|
437
|
+
try {
|
|
438
|
+
await q.setModel(config.model);
|
|
439
|
+
session.state.model = config.model;
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial model:`, err);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (!resumeSessionId) {
|
|
446
|
+
// New session — send seed message to activate streaming input mode
|
|
447
|
+
sendInput({
|
|
448
|
+
type: 'user',
|
|
449
|
+
message: {
|
|
450
|
+
role: 'user',
|
|
451
|
+
content: 'You are connected to Canon messaging. Messages from users will arrive as follow-up messages. Respond naturally — your text responses will be sent back to the conversation. You have full Claude Code capabilities (file read/write, bash, etc).',
|
|
452
|
+
},
|
|
453
|
+
parent_tool_use_id: null,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
// Write available models to session state
|
|
457
|
+
try {
|
|
458
|
+
const models = await q.supportedModels();
|
|
459
|
+
const modelList = toModelOptions(models);
|
|
460
|
+
session.availableModels = modelList;
|
|
461
|
+
runtimeModels = modelList;
|
|
462
|
+
await publishAgentRuntime(agentId, {
|
|
463
|
+
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
464
|
+
defaultModel: session.state.model ?? modelList[0]?.value,
|
|
465
|
+
defaultPermissionMode: 'default',
|
|
466
|
+
availableModels: modelList,
|
|
467
|
+
availableWorkspaces: toPublicWorkspaceOptions(),
|
|
468
|
+
}).catch(() => { });
|
|
469
|
+
writeSessionState(conversationId, agentId, {
|
|
470
|
+
...session.state,
|
|
471
|
+
availableModels: modelList,
|
|
472
|
+
cwd,
|
|
473
|
+
hostMode: true,
|
|
474
|
+
isActive: true,
|
|
475
|
+
}).catch(() => { });
|
|
476
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${models.map((m) => m.displayName).join(', ')}`);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
writeState();
|
|
480
|
+
}
|
|
481
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${resumeSessionId ? 'resumed' : 'started'} (cwd: ${cwd})`);
|
|
482
|
+
};
|
|
483
|
+
init().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err));
|
|
484
|
+
return session;
|
|
485
|
+
}
|
|
486
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
487
|
+
async function main() {
|
|
488
|
+
const { values: args } = parseArgs({
|
|
489
|
+
options: {
|
|
490
|
+
cwd: { type: 'string' },
|
|
491
|
+
workspace: { type: 'string', multiple: true },
|
|
492
|
+
},
|
|
493
|
+
strict: false,
|
|
494
|
+
});
|
|
495
|
+
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
496
|
+
const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
|
|
497
|
+
workspaceOptions = buildWorkspaceOptions(workingDir, configuredWorkspaces);
|
|
498
|
+
const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-host]' });
|
|
499
|
+
console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
500
|
+
// ── Canon API client + RTDB auth ──
|
|
501
|
+
const client = new CanonClient(apiKey);
|
|
502
|
+
initRTDBAuth(client);
|
|
503
|
+
let agentId;
|
|
504
|
+
let ownerId = null;
|
|
505
|
+
try {
|
|
506
|
+
const ctx = await client.getAgentMe();
|
|
507
|
+
agentId = ctx.agentId;
|
|
508
|
+
ownerId = ctx.ownerId;
|
|
509
|
+
console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
if (profileAgentId) {
|
|
513
|
+
agentId = profileAgentId;
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
const auth = await client.getAuthToken();
|
|
517
|
+
agentId = auth.agentId;
|
|
518
|
+
}
|
|
519
|
+
console.error(`[canon-host] Authenticated as ${agentId}`);
|
|
520
|
+
}
|
|
521
|
+
// ── Load conversations (informational — sessions created on demand) ──
|
|
522
|
+
try {
|
|
523
|
+
runtimeModels = await detectRuntimeModels(workingDir);
|
|
524
|
+
await publishAgentRuntime(agentId, {
|
|
525
|
+
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
526
|
+
defaultModel: runtimeModels[0]?.value,
|
|
527
|
+
defaultPermissionMode: 'default',
|
|
528
|
+
availableModels: runtimeModels,
|
|
529
|
+
availableWorkspaces: toPublicWorkspaceOptions(),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
console.error('[canon-host] Failed to publish agent runtime:', error);
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const convos = await client.getConversations();
|
|
537
|
+
console.error(`[canon-host] ${convos.length} conversations`);
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
console.error('[canon-host] Could not load conversations');
|
|
541
|
+
}
|
|
542
|
+
// ── Session manager ──
|
|
543
|
+
const sessions = new Map();
|
|
544
|
+
const pendingSessionCreations = new Map();
|
|
545
|
+
/** Saved SDK session IDs for resume after idle eviction */
|
|
546
|
+
const savedSessionIds = new Map();
|
|
547
|
+
function closeSession(conversationId) {
|
|
548
|
+
const session = sessions.get(conversationId);
|
|
549
|
+
if (!session)
|
|
550
|
+
return;
|
|
551
|
+
session.closed = true;
|
|
552
|
+
// Save SDK session ID for future resume
|
|
553
|
+
if (session.sdkSessionId) {
|
|
554
|
+
savedSessionIds.set(conversationId, session.sdkSessionId);
|
|
555
|
+
}
|
|
556
|
+
if (session.streamingTimer)
|
|
557
|
+
clearTimeout(session.streamingTimer);
|
|
558
|
+
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
559
|
+
clearSessionState(conversationId, agentId).catch(() => { });
|
|
560
|
+
client.setTyping(conversationId, false).catch(() => { });
|
|
561
|
+
try {
|
|
562
|
+
session.query.close();
|
|
563
|
+
}
|
|
564
|
+
catch { }
|
|
565
|
+
sessions.delete(conversationId);
|
|
566
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session suspended (sessionId saved for resume)`);
|
|
567
|
+
}
|
|
568
|
+
function evictOldestIdle() {
|
|
569
|
+
let oldest = null;
|
|
570
|
+
for (const s of sessions.values()) {
|
|
571
|
+
if (s.state.state === 'running')
|
|
572
|
+
continue; // Don't evict running sessions
|
|
573
|
+
if (!oldest || s.lastActivity < oldest.lastActivity)
|
|
574
|
+
oldest = s;
|
|
575
|
+
}
|
|
576
|
+
if (oldest) {
|
|
577
|
+
console.error(`[canon-host] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
|
|
578
|
+
closeSession(oldest.conversationId);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function getOrCreateSession(conversationId) {
|
|
582
|
+
const existing = sessions.get(conversationId);
|
|
583
|
+
if (existing && !existing.closed) {
|
|
584
|
+
existing.lastActivity = Date.now();
|
|
585
|
+
return existing;
|
|
586
|
+
}
|
|
587
|
+
const pending = pendingSessionCreations.get(conversationId);
|
|
588
|
+
if (pending)
|
|
589
|
+
return pending;
|
|
590
|
+
// Evict if at capacity
|
|
591
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
592
|
+
evictOldestIdle();
|
|
593
|
+
}
|
|
594
|
+
const creation = (async () => {
|
|
595
|
+
const config = await loadSessionConfig(conversationId, agentId);
|
|
596
|
+
const resumeId = savedSessionIds.get(conversationId);
|
|
597
|
+
const session = createSession(conversationId, resolveWorkspaceCwd(config), agentId, client, (convoId, sdkSessionId) => {
|
|
598
|
+
// Called when session processing loop ends — save session ID for resume
|
|
599
|
+
if (sdkSessionId)
|
|
600
|
+
savedSessionIds.set(convoId, sdkSessionId);
|
|
601
|
+
sessions.delete(convoId);
|
|
602
|
+
clearSessionState(convoId, agentId).catch(() => { });
|
|
603
|
+
}, {
|
|
604
|
+
model: config?.model ?? runtimeModels[0]?.value,
|
|
605
|
+
permissionMode: config?.permissionMode ?? 'default',
|
|
606
|
+
availableModels: runtimeModels,
|
|
607
|
+
}, resumeId);
|
|
608
|
+
sessions.set(conversationId, session);
|
|
609
|
+
return session;
|
|
610
|
+
})();
|
|
611
|
+
pendingSessionCreations.set(conversationId, creation);
|
|
612
|
+
try {
|
|
613
|
+
return await creation;
|
|
614
|
+
}
|
|
615
|
+
finally {
|
|
616
|
+
pendingSessionCreations.delete(conversationId);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// ── Connect SSE stream for inbound Canon messages ──
|
|
620
|
+
const stream = new CanonStream({
|
|
621
|
+
apiKey,
|
|
622
|
+
agentId,
|
|
623
|
+
handler: {
|
|
624
|
+
onMessage: (payload) => {
|
|
625
|
+
const m = payload.message;
|
|
626
|
+
if (m.senderId === agentId)
|
|
627
|
+
return;
|
|
628
|
+
const convoId = payload.conversationId;
|
|
629
|
+
// Check for metadata replies (question answers, plan approvals)
|
|
630
|
+
if (m.metadata?.type === 'question_reply' || m.metadata?.type === 'plan_approval_reply') {
|
|
631
|
+
const existingSession = sessions.get(convoId);
|
|
632
|
+
if (existingSession?.pendingReply) {
|
|
633
|
+
const { resolve } = existingSession.pendingReply;
|
|
634
|
+
existingSession.pendingReply = null;
|
|
635
|
+
existingSession.lastActivity = Date.now();
|
|
636
|
+
resolve(m.metadata);
|
|
637
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type})`);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const sender = m.senderName || m.senderId;
|
|
642
|
+
const content = renderInboundContent(m);
|
|
643
|
+
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
644
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}"`);
|
|
645
|
+
void (async () => {
|
|
646
|
+
const session = await getOrCreateSession(convoId);
|
|
647
|
+
session.sendInput({
|
|
648
|
+
type: 'user',
|
|
649
|
+
message: {
|
|
650
|
+
role: 'user',
|
|
651
|
+
content: buildCanonPrompt({
|
|
652
|
+
content,
|
|
653
|
+
conversationId: convoId,
|
|
654
|
+
isOwner,
|
|
655
|
+
senderName: sender,
|
|
656
|
+
}),
|
|
657
|
+
},
|
|
658
|
+
parent_tool_use_id: null,
|
|
659
|
+
});
|
|
660
|
+
})();
|
|
661
|
+
},
|
|
662
|
+
onConnected: () => console.error('[canon-host] SSE connected'),
|
|
663
|
+
onDisconnected: () => console.error('[canon-host] SSE disconnected'),
|
|
664
|
+
onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
stream.start().catch((err) => {
|
|
668
|
+
console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
|
|
669
|
+
});
|
|
670
|
+
// ── Control signal watcher ──
|
|
671
|
+
let controlStopped = false;
|
|
672
|
+
const lastSeenControl = new Map();
|
|
673
|
+
const lastSeenSignal = new Map();
|
|
674
|
+
const pollControl = async () => {
|
|
675
|
+
while (!controlStopped) {
|
|
676
|
+
for (const convoId of [...sessions.keys()]) {
|
|
677
|
+
try {
|
|
678
|
+
// Session control signals (model, mode)
|
|
679
|
+
const data = await rtdbRead(`/control/${convoId}/${agentId}/session`);
|
|
680
|
+
if (data && typeof data === 'object') {
|
|
681
|
+
const ctrl = data;
|
|
682
|
+
const ts = ctrl.updatedAt ?? 0;
|
|
683
|
+
if (ts > (lastSeenControl.get(convoId) ?? 0)) {
|
|
684
|
+
lastSeenControl.set(convoId, ts);
|
|
685
|
+
const session = sessions.get(convoId);
|
|
686
|
+
if (session && !session.closed) {
|
|
687
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
|
|
688
|
+
if (ctrl.model && ctrl.model !== session.state.model) {
|
|
689
|
+
try {
|
|
690
|
+
await session.query.setModel(ctrl.model);
|
|
691
|
+
session.state.model = ctrl.model;
|
|
692
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (ctrl.permissionMode && ctrl.permissionMode !== session.state.permissionMode) {
|
|
699
|
+
try {
|
|
700
|
+
await session.query.setPermissionMode(ctrl.permissionMode);
|
|
701
|
+
session.state.permissionMode = ctrl.permissionMode;
|
|
702
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${ctrl.permissionMode}`);
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (ctrl.effort && ctrl.effort !== session.state.effort) {
|
|
709
|
+
session.state.effort = ctrl.effort;
|
|
710
|
+
session.query.applyFlagSettings({
|
|
711
|
+
effortLevel: ctrl.effort,
|
|
712
|
+
}).catch((err) => console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err));
|
|
713
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${ctrl.effort}`);
|
|
714
|
+
}
|
|
715
|
+
writeSessionState(convoId, agentId, {
|
|
716
|
+
...session.state,
|
|
717
|
+
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
718
|
+
cwd: session.cwd,
|
|
719
|
+
hostMode: true,
|
|
720
|
+
isActive: true,
|
|
721
|
+
}).catch(() => { });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// Interrupt signal
|
|
726
|
+
const sig = await rtdbRead(`/control/${convoId}/${agentId}/signal`);
|
|
727
|
+
if (sig && typeof sig === 'object') {
|
|
728
|
+
const s = sig;
|
|
729
|
+
const ts = s.updatedAt ?? 0;
|
|
730
|
+
if (s.type === 'interrupt' && ts > (lastSeenSignal.get(convoId) ?? 0)) {
|
|
731
|
+
lastSeenSignal.set(convoId, ts);
|
|
732
|
+
const session = sessions.get(convoId);
|
|
733
|
+
if (session && !session.closed) {
|
|
734
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Interrupt signal`);
|
|
735
|
+
try {
|
|
736
|
+
await session.query.interrupt();
|
|
737
|
+
if (session.streamingTimer) {
|
|
738
|
+
clearTimeout(session.streamingTimer);
|
|
739
|
+
session.streamingTimer = null;
|
|
740
|
+
}
|
|
741
|
+
session.streamingText = '';
|
|
742
|
+
rtdbWrite(`/streaming/${convoId}/${agentId}`, null).catch(() => { });
|
|
743
|
+
client.setTyping(convoId, false).catch(() => { });
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Interrupt failed:`, err);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
// Transient RTDB errors — ignore and retry next cycle
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
await new Promise((r) => setTimeout(r, CONTROL_POLL_MS));
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
pollControl().catch((err) => console.error('[canon-host] Control poll crashed:', err));
|
|
760
|
+
// ── Heartbeat (per-session) ──
|
|
761
|
+
const heartbeat = setInterval(() => {
|
|
762
|
+
for (const session of sessions.values()) {
|
|
763
|
+
writeSessionState(session.conversationId, agentId, {
|
|
764
|
+
...session.state,
|
|
765
|
+
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
766
|
+
cwd: session.cwd,
|
|
767
|
+
hostMode: true,
|
|
768
|
+
isActive: true,
|
|
769
|
+
}).catch(() => { });
|
|
770
|
+
}
|
|
771
|
+
}, HEARTBEAT_MS);
|
|
772
|
+
// ── Idle session cleanup ──
|
|
773
|
+
const idleCheck = setInterval(() => {
|
|
774
|
+
const now = Date.now();
|
|
775
|
+
for (const convoId of [...sessions.keys()]) {
|
|
776
|
+
const session = sessions.get(convoId);
|
|
777
|
+
if (!session || session.state.state === 'running')
|
|
778
|
+
continue;
|
|
779
|
+
if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
|
|
780
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Idle timeout`);
|
|
781
|
+
closeSession(convoId);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}, IDLE_CHECK_MS);
|
|
785
|
+
// ── Graceful shutdown ──
|
|
786
|
+
const shutdown = async () => {
|
|
787
|
+
console.error('[canon-host] Shutting down...');
|
|
788
|
+
controlStopped = true;
|
|
789
|
+
clearInterval(heartbeat);
|
|
790
|
+
clearInterval(idleCheck);
|
|
791
|
+
stream.stop();
|
|
792
|
+
for (const convoId of [...sessions.keys()]) {
|
|
793
|
+
closeSession(convoId);
|
|
794
|
+
}
|
|
795
|
+
const ap = getActiveProfile();
|
|
796
|
+
if (ap)
|
|
797
|
+
releaseLock(ap);
|
|
798
|
+
process.exit(0);
|
|
799
|
+
};
|
|
800
|
+
process.on('SIGINT', shutdown);
|
|
801
|
+
process.on('SIGTERM', shutdown);
|
|
802
|
+
console.error('[canon-host] Ready — sessions created on demand');
|
|
803
|
+
// Keep alive
|
|
804
|
+
await new Promise(() => { });
|
|
805
|
+
}
|
|
806
|
+
// ── Entry ───────────────────────────────────────────────────────────
|
|
807
|
+
main().catch((err) => {
|
|
808
|
+
console.error('[canon-host] Fatal error:', err);
|
|
809
|
+
const ap = getActiveProfile();
|
|
810
|
+
if (ap)
|
|
811
|
+
releaseLock(ap);
|
|
812
|
+
process.exit(1);
|
|
813
|
+
});
|