@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/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
+ });