@canonmsg/claude-code-plugin 0.3.0 → 0.4.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 +289 -22
- package/dist/inbound-policy.d.ts +19 -0
- package/dist/inbound-policy.js +50 -0
- package/dist/register.js +0 -0
- package/dist/server.js +85 -13
- package/dist/setup.d.ts +1 -1
- package/dist/setup.js +1 -1
- package/package.json +5 -5
- package/dist/agent-profiles.d.ts +0 -21
- package/dist/agent-profiles.js +0 -63
- package/dist/rtdb-rest.d.ts +0 -34
- package/dist/rtdb-rest.js +0 -128
package/dist/host.js
CHANGED
|
@@ -25,7 +25,8 @@ import { parseArgs } from 'node:util';
|
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
26
|
import { basename, resolve } from 'node:path';
|
|
27
27
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
28
|
-
import { CanonClient, CanonStream, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, } from '@canonmsg/core';
|
|
28
|
+
import { CanonClient, CanonStream, clearTurnState, DEFAULT_RUNTIME_CAPABILITIES, normalizeTurnMetadata, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, writeTurnState, } from '@canonmsg/core';
|
|
29
|
+
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
29
30
|
let workingDir = process.cwd();
|
|
30
31
|
let workspaceOptions = [];
|
|
31
32
|
let runtimeModels = [];
|
|
@@ -124,18 +125,25 @@ const STREAMING_THROTTLE_MS = 250;
|
|
|
124
125
|
const HEARTBEAT_MS = 30_000;
|
|
125
126
|
const IDLE_CHECK_MS = 60_000;
|
|
126
127
|
const CONTROL_POLL_MS = 2000;
|
|
128
|
+
const COMPLETED_STATE_HOLD_MS = 750;
|
|
129
|
+
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
130
|
+
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
131
|
+
supportsInterrupt: true,
|
|
132
|
+
supportsQueue: true,
|
|
133
|
+
supportsInterleave: true,
|
|
134
|
+
supportsRequiresAction: true,
|
|
135
|
+
supportsNonFinalPermanentMessages: true,
|
|
136
|
+
};
|
|
127
137
|
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
138
|
return [
|
|
132
139
|
'You are connected to Canon messaging through a Claude Code host wrapper.',
|
|
133
|
-
'Reply naturally to
|
|
140
|
+
'Reply naturally to Canon participants.',
|
|
134
141
|
'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
|
|
135
142
|
'Short intermediate assistant messages may be shown as ephemeral status while you work.',
|
|
136
|
-
|
|
143
|
+
...buildInboundContextLines(input.participantContext),
|
|
144
|
+
'Canon participants may be humans or AI agents.',
|
|
145
|
+
'If the latest message came from another AI agent, avoid agent-only ping-pong and only reply when directly addressed, when a human is clearly steering, or when a reply is genuinely necessary.',
|
|
137
146
|
`Conversation ID: ${input.conversationId}`,
|
|
138
|
-
`Sender: ${input.senderName}`,
|
|
139
147
|
'',
|
|
140
148
|
'New Canon message:',
|
|
141
149
|
input.content,
|
|
@@ -143,7 +151,25 @@ function buildCanonPrompt(input) {
|
|
|
143
151
|
}
|
|
144
152
|
function renderInboundContent(message) {
|
|
145
153
|
let content = message.text || '';
|
|
146
|
-
|
|
154
|
+
const attachment = message.attachments?.[0];
|
|
155
|
+
if (attachment?.kind === 'audio' && attachment.url) {
|
|
156
|
+
const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
|
|
157
|
+
content = content
|
|
158
|
+
? `[Voice message${duration}: ${attachment.url}]\n${content}`
|
|
159
|
+
: `[Voice message${duration}: ${attachment.url}]`;
|
|
160
|
+
}
|
|
161
|
+
else if (attachment?.kind === 'image' && attachment.url) {
|
|
162
|
+
content = content
|
|
163
|
+
? `[Image: ${attachment.url}]\n${content}`
|
|
164
|
+
: `[Image: ${attachment.url}]`;
|
|
165
|
+
}
|
|
166
|
+
else if (attachment?.kind === 'file' && attachment.url) {
|
|
167
|
+
const label = attachment.fileName || 'File';
|
|
168
|
+
content = content
|
|
169
|
+
? `[File: ${label} ${attachment.url}]\n${content}`
|
|
170
|
+
: `[File: ${label} ${attachment.url}]`;
|
|
171
|
+
}
|
|
172
|
+
else if (message.contentType === 'audio' && message.audioUrl) {
|
|
147
173
|
const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
|
|
148
174
|
content = content
|
|
149
175
|
? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
|
|
@@ -173,6 +199,9 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
173
199
|
messageQueue.push(msg);
|
|
174
200
|
}
|
|
175
201
|
};
|
|
202
|
+
let enqueueInboundMessage = (msg, _intent = 'queue', _sourceMessageId, _markAccepted = false) => {
|
|
203
|
+
sendInput(msg);
|
|
204
|
+
};
|
|
176
205
|
const inputStream = {
|
|
177
206
|
[Symbol.asyncIterator]() {
|
|
178
207
|
return {
|
|
@@ -232,6 +261,8 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
232
261
|
type: 'claude_question',
|
|
233
262
|
questionId,
|
|
234
263
|
questions,
|
|
264
|
+
turnSemantics: 'control',
|
|
265
|
+
replyBehavior: 'suppress_auto_reply',
|
|
235
266
|
},
|
|
236
267
|
});
|
|
237
268
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Question sent (${questionId.slice(0, 8)})`);
|
|
@@ -249,7 +280,12 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
249
280
|
try {
|
|
250
281
|
const planId = randomUUID();
|
|
251
282
|
await client.sendMessage(conversationId, 'Plan ready for review.', {
|
|
252
|
-
metadata: {
|
|
283
|
+
metadata: {
|
|
284
|
+
type: 'plan_approval',
|
|
285
|
+
planId,
|
|
286
|
+
turnSemantics: 'control',
|
|
287
|
+
replyBehavior: 'suppress_auto_reply',
|
|
288
|
+
},
|
|
253
289
|
});
|
|
254
290
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
|
|
255
291
|
const reply = await waitForReply();
|
|
@@ -281,6 +317,7 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
281
317
|
cwd,
|
|
282
318
|
query: q,
|
|
283
319
|
sendInput,
|
|
320
|
+
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted),
|
|
284
321
|
state: {
|
|
285
322
|
model: config.model,
|
|
286
323
|
permissionMode: config.permissionMode,
|
|
@@ -289,7 +326,15 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
289
326
|
availableModels: config.availableModels,
|
|
290
327
|
streamingText: '',
|
|
291
328
|
streamingTimer: null,
|
|
329
|
+
idleResetTimer: null,
|
|
292
330
|
seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
|
|
331
|
+
pendingInputs: [],
|
|
332
|
+
pendingFinalText: null,
|
|
333
|
+
toolInProgress: false,
|
|
334
|
+
turnState: 'idle',
|
|
335
|
+
currentTurnId: null,
|
|
336
|
+
currentTurnOpenedAt: null,
|
|
337
|
+
lastAcceptedIntent: null,
|
|
293
338
|
lastActivity: Date.now(),
|
|
294
339
|
closed: false,
|
|
295
340
|
sdkSessionId: resumeSessionId || null,
|
|
@@ -322,6 +367,31 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
322
367
|
session.streamingText = '';
|
|
323
368
|
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
324
369
|
}
|
|
370
|
+
function clearIdleResetTimer() {
|
|
371
|
+
if (!session.idleResetTimer)
|
|
372
|
+
return;
|
|
373
|
+
clearTimeout(session.idleResetTimer);
|
|
374
|
+
session.idleResetTimer = null;
|
|
375
|
+
}
|
|
376
|
+
function resetTurnToIdle() {
|
|
377
|
+
if (session.closed)
|
|
378
|
+
return;
|
|
379
|
+
session.currentTurnId = null;
|
|
380
|
+
session.currentTurnOpenedAt = null;
|
|
381
|
+
session.lastAcceptedIntent = null;
|
|
382
|
+
session.pendingFinalText = null;
|
|
383
|
+
session.toolInProgress = false;
|
|
384
|
+
session.turnState = 'idle';
|
|
385
|
+
writeTurn();
|
|
386
|
+
drainPendingInput();
|
|
387
|
+
}
|
|
388
|
+
function scheduleIdleTurnReset() {
|
|
389
|
+
clearIdleResetTimer();
|
|
390
|
+
session.idleResetTimer = setTimeout(() => {
|
|
391
|
+
session.idleResetTimer = null;
|
|
392
|
+
resetTurnToIdle();
|
|
393
|
+
}, COMPLETED_STATE_HOLD_MS);
|
|
394
|
+
}
|
|
325
395
|
function writeState() {
|
|
326
396
|
writeSessionState(conversationId, agentId, {
|
|
327
397
|
...session.state,
|
|
@@ -331,6 +401,56 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
331
401
|
isActive: true,
|
|
332
402
|
}).catch(() => { });
|
|
333
403
|
}
|
|
404
|
+
function writeTurn() {
|
|
405
|
+
writeTurnState(conversationId, agentId, {
|
|
406
|
+
turnId: session.currentTurnId,
|
|
407
|
+
state: session.turnState,
|
|
408
|
+
queueDepth: session.pendingInputs.length,
|
|
409
|
+
currentSpeakerId: agentId,
|
|
410
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
411
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
412
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
413
|
+
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
414
|
+
? { completedAt: { '.sv': 'timestamp' } }
|
|
415
|
+
: {}),
|
|
416
|
+
}).catch(() => { });
|
|
417
|
+
}
|
|
418
|
+
async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
|
|
419
|
+
if (!markAccepted || !sourceMessageId)
|
|
420
|
+
return;
|
|
421
|
+
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
422
|
+
}
|
|
423
|
+
function drainPendingInput() {
|
|
424
|
+
if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
|
|
425
|
+
return;
|
|
426
|
+
const next = session.pendingInputs.shift();
|
|
427
|
+
if (!next)
|
|
428
|
+
return;
|
|
429
|
+
session.lastAcceptedIntent = next.intent;
|
|
430
|
+
writeTurn();
|
|
431
|
+
void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
|
|
432
|
+
.finally(() => sendInput(next.msg));
|
|
433
|
+
}
|
|
434
|
+
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
|
|
435
|
+
session.lastActivity = Date.now();
|
|
436
|
+
if (session.state.state === 'running' && intent !== 'interrupt') {
|
|
437
|
+
session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
|
|
438
|
+
writeTurn();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (session.state.state === 'running' && intent === 'interrupt') {
|
|
442
|
+
session.pendingInputs.unshift({ msg, intent, sourceMessageId, markAccepted });
|
|
443
|
+
session.turnState = 'interrupted';
|
|
444
|
+
writeTurn();
|
|
445
|
+
void session.query.interrupt().catch(() => { });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
session.lastAcceptedIntent = intent;
|
|
449
|
+
writeTurn();
|
|
450
|
+
void markQueuedMessageAccepted(sourceMessageId, markAccepted)
|
|
451
|
+
.finally(() => sendInput(msg));
|
|
452
|
+
};
|
|
453
|
+
writeTurn();
|
|
334
454
|
// ── Process SDK output messages ──
|
|
335
455
|
const processMessages = async () => {
|
|
336
456
|
try {
|
|
@@ -353,19 +473,28 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
353
473
|
const fullText = textBlocks.join('\n').trim();
|
|
354
474
|
clearStreaming();
|
|
355
475
|
if (fullText) {
|
|
356
|
-
|
|
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
|
-
}
|
|
476
|
+
session.pendingFinalText = fullText;
|
|
363
477
|
}
|
|
364
478
|
break;
|
|
365
479
|
}
|
|
366
480
|
case 'stream_event': {
|
|
367
481
|
const event = msg.event;
|
|
368
|
-
if (event?.type === '
|
|
482
|
+
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
483
|
+
session.toolInProgress = true;
|
|
484
|
+
session.turnState = 'tool';
|
|
485
|
+
writeTurn();
|
|
486
|
+
}
|
|
487
|
+
else if (event?.type === 'content_block_stop' && session.toolInProgress) {
|
|
488
|
+
session.toolInProgress = false;
|
|
489
|
+
if (session.state.state === 'running') {
|
|
490
|
+
session.turnState = 'thinking';
|
|
491
|
+
writeTurn();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
else if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
495
|
+
session.toolInProgress = false;
|
|
496
|
+
session.turnState = 'streaming';
|
|
497
|
+
writeTurn();
|
|
369
498
|
onStreamDelta(event.delta.text);
|
|
370
499
|
}
|
|
371
500
|
break;
|
|
@@ -376,16 +505,36 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
376
505
|
const state = msg.state;
|
|
377
506
|
session.state.state = state;
|
|
378
507
|
if (state === 'running') {
|
|
508
|
+
clearIdleResetTimer();
|
|
509
|
+
if (!session.currentTurnId) {
|
|
510
|
+
session.currentTurnId = randomUUID();
|
|
511
|
+
session.currentTurnOpenedAt = Date.now();
|
|
512
|
+
}
|
|
513
|
+
session.pendingFinalText = null;
|
|
514
|
+
session.toolInProgress = false;
|
|
515
|
+
session.turnState = 'thinking';
|
|
379
516
|
client.setTyping(conversationId, true, 'thinking').catch(() => { });
|
|
380
517
|
}
|
|
381
518
|
else if (state === 'idle') {
|
|
519
|
+
clearIdleResetTimer();
|
|
520
|
+
session.turnState = 'completed';
|
|
382
521
|
client.setTyping(conversationId, false).catch(() => { });
|
|
383
522
|
clearStreaming();
|
|
523
|
+
writeTurn();
|
|
524
|
+
scheduleIdleTurnReset();
|
|
384
525
|
}
|
|
385
526
|
else if (state === 'requires_action') {
|
|
527
|
+
clearIdleResetTimer();
|
|
528
|
+
if (!session.currentTurnId) {
|
|
529
|
+
session.currentTurnId = randomUUID();
|
|
530
|
+
session.currentTurnOpenedAt = Date.now();
|
|
531
|
+
}
|
|
532
|
+
session.toolInProgress = false;
|
|
533
|
+
session.turnState = 'waiting_input';
|
|
386
534
|
client.setTyping(conversationId, false).catch(() => { });
|
|
387
535
|
}
|
|
388
536
|
writeState();
|
|
537
|
+
writeTurn();
|
|
389
538
|
}
|
|
390
539
|
break;
|
|
391
540
|
}
|
|
@@ -395,6 +544,26 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
395
544
|
if (resultSessionId)
|
|
396
545
|
session.sdkSessionId = resultSessionId;
|
|
397
546
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
|
|
547
|
+
const resultText = typeof msg.result === 'string'
|
|
548
|
+
? msg.result.trim()
|
|
549
|
+
: '';
|
|
550
|
+
const finalText = resultText || session.pendingFinalText?.trim() || null;
|
|
551
|
+
if (finalText) {
|
|
552
|
+
try {
|
|
553
|
+
await client.sendMessage(conversationId, finalText, {
|
|
554
|
+
metadata: {
|
|
555
|
+
turnId: session.currentTurnId,
|
|
556
|
+
turnSemantics: 'turn_complete',
|
|
557
|
+
turnComplete: true,
|
|
558
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
398
567
|
try {
|
|
399
568
|
const usage = await q.getContextUsage();
|
|
400
569
|
writeSessionState(conversationId, agentId, {
|
|
@@ -413,6 +582,9 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
413
582
|
catch {
|
|
414
583
|
// Non-fatal
|
|
415
584
|
}
|
|
585
|
+
if (session.state.state !== 'running' && !session.idleResetTimer) {
|
|
586
|
+
drainPendingInput();
|
|
587
|
+
}
|
|
416
588
|
break;
|
|
417
589
|
}
|
|
418
590
|
default:
|
|
@@ -544,6 +716,59 @@ async function main() {
|
|
|
544
716
|
const pendingSessionCreations = new Map();
|
|
545
717
|
/** Saved SDK session IDs for resume after idle eviction */
|
|
546
718
|
const savedSessionIds = new Map();
|
|
719
|
+
const conversationCache = new Map();
|
|
720
|
+
let conversationCacheLoadedAt = 0;
|
|
721
|
+
async function refreshConversationCache(force = false) {
|
|
722
|
+
if (!force && conversationCache.size > 0 && Date.now() - conversationCacheLoadedAt < 10_000) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const conversations = await client.getConversations();
|
|
726
|
+
conversationCache.clear();
|
|
727
|
+
for (const conversation of conversations) {
|
|
728
|
+
conversationCache.set(conversation.id, conversation);
|
|
729
|
+
}
|
|
730
|
+
conversationCacheLoadedAt = Date.now();
|
|
731
|
+
}
|
|
732
|
+
async function getConversationMeta(conversationId) {
|
|
733
|
+
try {
|
|
734
|
+
await refreshConversationCache();
|
|
735
|
+
const cached = conversationCache.get(conversationId);
|
|
736
|
+
if (cached)
|
|
737
|
+
return cached;
|
|
738
|
+
await refreshConversationCache(true);
|
|
739
|
+
return conversationCache.get(conversationId) ?? null;
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return conversationCache.get(conversationId) ?? null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async function loadParticipantContext(input) {
|
|
746
|
+
const [conversation, recentMessages] = await Promise.all([
|
|
747
|
+
getConversationMeta(input.conversationId),
|
|
748
|
+
client.getMessages(input.conversationId, 6).catch(() => []),
|
|
749
|
+
]);
|
|
750
|
+
const recentSenderTypes = recentMessages
|
|
751
|
+
.filter((message) => message.senderId !== agentId)
|
|
752
|
+
.map((message) => message.senderType);
|
|
753
|
+
let consecutiveAgentTurns = 0;
|
|
754
|
+
for (const senderType of recentSenderTypes) {
|
|
755
|
+
if (senderType !== 'ai_agent')
|
|
756
|
+
break;
|
|
757
|
+
consecutiveAgentTurns += 1;
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
conversationType: conversation?.type ?? 'unknown',
|
|
761
|
+
memberCount: conversation?.memberIds?.length ?? null,
|
|
762
|
+
senderType: input.message.senderType ?? 'human',
|
|
763
|
+
senderName: input.senderName,
|
|
764
|
+
isOwner: input.isOwner,
|
|
765
|
+
mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
|
|
766
|
+
recentSenderTypes,
|
|
767
|
+
recentHumanCount: recentSenderTypes.filter((senderType) => senderType === 'human').length,
|
|
768
|
+
recentAgentCount: recentSenderTypes.filter((senderType) => senderType === 'ai_agent').length,
|
|
769
|
+
consecutiveAgentTurns,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
547
772
|
function closeSession(conversationId) {
|
|
548
773
|
const session = sessions.get(conversationId);
|
|
549
774
|
if (!session)
|
|
@@ -555,8 +780,13 @@ async function main() {
|
|
|
555
780
|
}
|
|
556
781
|
if (session.streamingTimer)
|
|
557
782
|
clearTimeout(session.streamingTimer);
|
|
783
|
+
if (session.idleResetTimer) {
|
|
784
|
+
clearTimeout(session.idleResetTimer);
|
|
785
|
+
session.idleResetTimer = null;
|
|
786
|
+
}
|
|
558
787
|
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
559
788
|
clearSessionState(conversationId, agentId).catch(() => { });
|
|
789
|
+
clearTurnState(conversationId, agentId).catch(() => { });
|
|
560
790
|
client.setTyping(conversationId, false).catch(() => { });
|
|
561
791
|
try {
|
|
562
792
|
session.query.close();
|
|
@@ -600,6 +830,7 @@ async function main() {
|
|
|
600
830
|
savedSessionIds.set(convoId, sdkSessionId);
|
|
601
831
|
sessions.delete(convoId);
|
|
602
832
|
clearSessionState(convoId, agentId).catch(() => { });
|
|
833
|
+
clearTurnState(convoId, agentId).catch(() => { });
|
|
603
834
|
}, {
|
|
604
835
|
model: config?.model ?? runtimeModels[0]?.value,
|
|
605
836
|
permissionMode: config?.permissionMode ?? 'default',
|
|
@@ -641,22 +872,35 @@ async function main() {
|
|
|
641
872
|
const sender = m.senderName || m.senderId;
|
|
642
873
|
const content = renderInboundContent(m);
|
|
643
874
|
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
875
|
void (async () => {
|
|
876
|
+
const participantContext = await loadParticipantContext({
|
|
877
|
+
conversationId: convoId,
|
|
878
|
+
message: m,
|
|
879
|
+
senderName: sender,
|
|
880
|
+
isOwner,
|
|
881
|
+
});
|
|
882
|
+
const autoReply = decideAutoReply(participantContext);
|
|
883
|
+
if (!autoReply.allow) {
|
|
884
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
646
888
|
const session = await getOrCreateSession(convoId);
|
|
647
|
-
|
|
889
|
+
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
890
|
+
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
891
|
+
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
892
|
+
session.enqueueInbound({
|
|
648
893
|
type: 'user',
|
|
649
894
|
message: {
|
|
650
895
|
role: 'user',
|
|
651
896
|
content: buildCanonPrompt({
|
|
652
897
|
content,
|
|
653
898
|
conversationId: convoId,
|
|
654
|
-
|
|
655
|
-
senderName: sender,
|
|
899
|
+
participantContext,
|
|
656
900
|
}),
|
|
657
901
|
},
|
|
658
902
|
parent_tool_use_id: null,
|
|
659
|
-
});
|
|
903
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
|
660
904
|
})();
|
|
661
905
|
},
|
|
662
906
|
onConnected: () => console.error('[canon-host] SSE connected'),
|
|
@@ -733,6 +977,17 @@ async function main() {
|
|
|
733
977
|
if (session && !session.closed) {
|
|
734
978
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Interrupt signal`);
|
|
735
979
|
try {
|
|
980
|
+
session.turnState = 'interrupted';
|
|
981
|
+
writeTurnState(convoId, agentId, {
|
|
982
|
+
turnId: session.currentTurnId,
|
|
983
|
+
state: 'interrupted',
|
|
984
|
+
queueDepth: session.pendingInputs.length,
|
|
985
|
+
currentSpeakerId: agentId,
|
|
986
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
987
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
988
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
989
|
+
completedAt: { '.sv': 'timestamp' },
|
|
990
|
+
}).catch(() => { });
|
|
736
991
|
await session.query.interrupt();
|
|
737
992
|
if (session.streamingTimer) {
|
|
738
993
|
clearTimeout(session.streamingTimer);
|
|
@@ -767,6 +1022,18 @@ async function main() {
|
|
|
767
1022
|
hostMode: true,
|
|
768
1023
|
isActive: true,
|
|
769
1024
|
}).catch(() => { });
|
|
1025
|
+
writeTurnState(session.conversationId, agentId, {
|
|
1026
|
+
turnId: session.currentTurnId,
|
|
1027
|
+
state: session.turnState,
|
|
1028
|
+
queueDepth: session.pendingInputs.length,
|
|
1029
|
+
currentSpeakerId: agentId,
|
|
1030
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
1031
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1032
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
1033
|
+
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
1034
|
+
? { completedAt: { '.sv': 'timestamp' } }
|
|
1035
|
+
: {}),
|
|
1036
|
+
}).catch(() => { });
|
|
770
1037
|
}
|
|
771
1038
|
}, HEARTBEAT_MS);
|
|
772
1039
|
// ── Idle session cleanup ──
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CanonConversation } from '@canonmsg/core';
|
|
2
|
+
export interface InboundParticipantContext {
|
|
3
|
+
conversationType: CanonConversation['type'] | 'unknown';
|
|
4
|
+
memberCount: number | null;
|
|
5
|
+
senderType: 'human' | 'ai_agent';
|
|
6
|
+
senderName: string;
|
|
7
|
+
isOwner: boolean;
|
|
8
|
+
mentionedAgent: boolean;
|
|
9
|
+
recentSenderTypes: Array<'human' | 'ai_agent'>;
|
|
10
|
+
recentHumanCount: number;
|
|
11
|
+
recentAgentCount: number;
|
|
12
|
+
consecutiveAgentTurns: number;
|
|
13
|
+
}
|
|
14
|
+
export interface AutoReplyDecision {
|
|
15
|
+
allow: boolean;
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
|
|
19
|
+
export declare function decideAutoReply(context: InboundParticipantContext): AutoReplyDecision;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function formatRecentSenders(senderTypes) {
|
|
2
|
+
if (senderTypes.length === 0)
|
|
3
|
+
return 'none';
|
|
4
|
+
return senderTypes.map((senderType) => (senderType === 'ai_agent' ? 'agent' : 'human')).join(' -> ');
|
|
5
|
+
}
|
|
6
|
+
export function buildInboundContextLines(context) {
|
|
7
|
+
const conversationTypeLabel = context.conversationType === 'unknown'
|
|
8
|
+
? 'unknown'
|
|
9
|
+
: `${context.conversationType}${context.memberCount ? ` (${context.memberCount} members)` : ''}`;
|
|
10
|
+
const senderRole = context.isOwner
|
|
11
|
+
? 'The latest sender is the verified human owner of this Canon agent.'
|
|
12
|
+
: context.senderType === 'ai_agent'
|
|
13
|
+
? 'The latest sender is another AI agent in Canon.'
|
|
14
|
+
: 'The latest sender is a human Canon participant.';
|
|
15
|
+
return [
|
|
16
|
+
senderRole,
|
|
17
|
+
`Latest sender name: ${context.senderName}`,
|
|
18
|
+
`Latest sender type: ${context.senderType}`,
|
|
19
|
+
`Conversation type: ${conversationTypeLabel}`,
|
|
20
|
+
`Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
|
|
21
|
+
`Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
|
|
22
|
+
`Recent human messages: ${context.recentHumanCount}`,
|
|
23
|
+
`Recent agent messages: ${context.recentAgentCount}`,
|
|
24
|
+
`Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
export function decideAutoReply(context) {
|
|
28
|
+
if (context.senderType !== 'ai_agent') {
|
|
29
|
+
return { allow: true, reason: 'latest sender is human' };
|
|
30
|
+
}
|
|
31
|
+
if (context.isOwner) {
|
|
32
|
+
return { allow: true, reason: 'owner messages always pass through' };
|
|
33
|
+
}
|
|
34
|
+
if (context.mentionedAgent) {
|
|
35
|
+
return { allow: true, reason: 'another agent explicitly addressed this agent' };
|
|
36
|
+
}
|
|
37
|
+
if (context.conversationType === 'group') {
|
|
38
|
+
return {
|
|
39
|
+
allow: false,
|
|
40
|
+
reason: 'suppressing group agent auto-reply without a direct mention',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (context.consecutiveAgentTurns >= 2 && context.recentHumanCount === 0) {
|
|
44
|
+
return {
|
|
45
|
+
allow: false,
|
|
46
|
+
reason: 'suppressing likely agent-only loop in a direct conversation',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return { allow: true, reason: 'direct agent message allowed' };
|
|
50
|
+
}
|
package/dist/register.js
CHANGED
|
File without changes
|
package/dist/server.js
CHANGED
|
@@ -28,6 +28,43 @@ let approvalServer = null;
|
|
|
28
28
|
const conversationCache = new Map();
|
|
29
29
|
/** The conversation that most recently triggered work (for approval routing) */
|
|
30
30
|
let triggerConversationId = null;
|
|
31
|
+
function getPrimaryAttachment(message) {
|
|
32
|
+
const attachment = message.attachments?.[0];
|
|
33
|
+
if (attachment?.url)
|
|
34
|
+
return attachment;
|
|
35
|
+
if (message.audioUrl) {
|
|
36
|
+
return {
|
|
37
|
+
kind: 'audio',
|
|
38
|
+
url: message.audioUrl,
|
|
39
|
+
durationMs: message.audioDurationMs,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (message.imageUrl) {
|
|
43
|
+
return {
|
|
44
|
+
kind: 'image',
|
|
45
|
+
url: message.imageUrl,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function renderInboundContent(message) {
|
|
51
|
+
let content = message.text || '';
|
|
52
|
+
const attachment = getPrimaryAttachment(message);
|
|
53
|
+
if (attachment?.kind === 'audio') {
|
|
54
|
+
const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
|
|
55
|
+
return content
|
|
56
|
+
? `[Voice message${duration}: ${attachment.url}]\n${content}`
|
|
57
|
+
: `[Voice message${duration}: ${attachment.url}]`;
|
|
58
|
+
}
|
|
59
|
+
if (attachment?.kind === 'image') {
|
|
60
|
+
return content ? `[Image: ${attachment.url}]\n${content}` : `[Image: ${attachment.url}]`;
|
|
61
|
+
}
|
|
62
|
+
if (attachment?.kind === 'file') {
|
|
63
|
+
const label = attachment.fileName || 'File';
|
|
64
|
+
return content ? `[File: ${label} ${attachment.url}]\n${content}` : `[File: ${label} ${attachment.url}]`;
|
|
65
|
+
}
|
|
66
|
+
return content;
|
|
67
|
+
}
|
|
31
68
|
// ── Tool definitions ───────────────────────────────────────────────────
|
|
32
69
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
33
70
|
tools: [
|
|
@@ -51,7 +88,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
51
88
|
},
|
|
52
89
|
{
|
|
53
90
|
name: 'send_message',
|
|
54
|
-
description: 'Send a message to a Canon conversation (supports text, images, audio)',
|
|
91
|
+
description: 'Send a message to a Canon conversation (supports text, images, audio, files)',
|
|
55
92
|
inputSchema: {
|
|
56
93
|
type: 'object',
|
|
57
94
|
properties: {
|
|
@@ -65,7 +102,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
65
102
|
},
|
|
66
103
|
content_type: {
|
|
67
104
|
type: 'string',
|
|
68
|
-
enum: ['text', 'image', 'audio'],
|
|
105
|
+
enum: ['text', 'image', 'audio', 'file'],
|
|
69
106
|
description: 'Content type (default: text)',
|
|
70
107
|
},
|
|
71
108
|
image_url: {
|
|
@@ -76,8 +113,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
76
113
|
type: 'string',
|
|
77
114
|
description: 'Audio URL (when content_type is audio)',
|
|
78
115
|
},
|
|
116
|
+
file_url: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
description: 'File URL (when content_type is file)',
|
|
119
|
+
},
|
|
120
|
+
file_name: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
description: 'Optional display filename for file attachments',
|
|
123
|
+
},
|
|
79
124
|
},
|
|
80
|
-
required: ['conversation_id'
|
|
125
|
+
required: ['conversation_id'],
|
|
81
126
|
},
|
|
82
127
|
},
|
|
83
128
|
{
|
|
@@ -151,15 +196,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
151
196
|
}
|
|
152
197
|
case 'send_message': {
|
|
153
198
|
const conversationId = args.conversation_id;
|
|
154
|
-
const text = args.text;
|
|
199
|
+
const text = typeof args.text === 'string' ? args.text : '';
|
|
155
200
|
const contentType = args.content_type || 'text';
|
|
156
|
-
const opts = {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
opts.
|
|
201
|
+
const opts = {};
|
|
202
|
+
const imageUrl = typeof args.image_url === 'string' ? args.image_url : null;
|
|
203
|
+
const audioUrl = typeof args.audio_url === 'string' ? args.audio_url : null;
|
|
204
|
+
const fileUrl = typeof args.file_url === 'string' ? args.file_url : null;
|
|
205
|
+
const fileName = typeof args.file_name === 'string' ? args.file_name : null;
|
|
206
|
+
if (contentType === 'image' && imageUrl) {
|
|
207
|
+
opts.contentType = 'image';
|
|
208
|
+
opts.attachments = [{ kind: 'image', url: imageUrl }];
|
|
209
|
+
}
|
|
210
|
+
else if (contentType === 'audio' && audioUrl) {
|
|
211
|
+
opts.contentType = 'audio';
|
|
212
|
+
opts.attachments = [{ kind: 'audio', url: audioUrl }];
|
|
213
|
+
}
|
|
214
|
+
else if (contentType === 'file' || fileUrl) {
|
|
215
|
+
opts.contentType = 'file';
|
|
216
|
+
if (fileUrl) {
|
|
217
|
+
opts.attachments = [{
|
|
218
|
+
kind: 'file',
|
|
219
|
+
url: fileUrl,
|
|
220
|
+
...(fileName ? { fileName } : {}),
|
|
221
|
+
}];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
opts.contentType = contentType;
|
|
226
|
+
}
|
|
163
227
|
const result = await client.sendMessage(conversationId, text, opts);
|
|
164
228
|
return {
|
|
165
229
|
content: [
|
|
@@ -261,16 +325,24 @@ async function handleInboundMessage(payload) {
|
|
|
261
325
|
content_type: m.contentType || 'text',
|
|
262
326
|
ts: new Date().toISOString(),
|
|
263
327
|
};
|
|
328
|
+
const primaryAttachment = getPrimaryAttachment(m);
|
|
264
329
|
if (m.imageUrl)
|
|
265
330
|
meta.image_url = m.imageUrl;
|
|
266
331
|
if (m.audioUrl)
|
|
267
332
|
meta.audio_url = m.audioUrl;
|
|
268
|
-
|
|
333
|
+
if (primaryAttachment?.url) {
|
|
334
|
+
meta.attachment_kind = primaryAttachment.kind;
|
|
335
|
+
meta.attachment_url = primaryAttachment.url;
|
|
336
|
+
}
|
|
337
|
+
if (primaryAttachment?.fileName)
|
|
338
|
+
meta.attachment_file_name = primaryAttachment.fileName;
|
|
339
|
+
const renderedContent = renderInboundContent(m);
|
|
340
|
+
console.error(`[canon] Inbound message from ${meta.sender_name}: "${renderedContent}"`);
|
|
269
341
|
try {
|
|
270
342
|
await server.notification({
|
|
271
343
|
method: 'notifications/claude/channel',
|
|
272
344
|
params: {
|
|
273
|
-
content:
|
|
345
|
+
content: renderedContent,
|
|
274
346
|
meta,
|
|
275
347
|
},
|
|
276
348
|
});
|
package/dist/setup.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Setup script for the Canon Claude Code plugin.
|
|
4
4
|
*
|
|
5
|
-
* Run after `npm install -g @
|
|
5
|
+
* Run after `npm install -g @canonmsg/claude-code-plugin` to:
|
|
6
6
|
* 1. Install the /canon-register and /canon-configure skills
|
|
7
7
|
* 2. Show the user how to add the MCP server to their .mcp.json
|
|
8
8
|
*/
|
package/dist/setup.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Setup script for the Canon Claude Code plugin.
|
|
4
4
|
*
|
|
5
|
-
* Run after `npm install -g @
|
|
5
|
+
* Run after `npm install -g @canonmsg/claude-code-plugin` to:
|
|
6
6
|
* 1. Install the /canon-register and /canon-configure skills
|
|
7
7
|
* 2. Show the user how to add the MCP server to their .mcp.json
|
|
8
8
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/server.js",
|
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
".claude-plugin"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"build": "tsc",
|
|
19
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
20
20
|
"dev": "tsc --watch",
|
|
21
|
-
"
|
|
21
|
+
"prepack": "npm run build"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
25
|
-
"@canonmsg/core": "^0.
|
|
24
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.101",
|
|
25
|
+
"@canonmsg/core": "^0.3.0",
|
|
26
26
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
package/dist/agent-profiles.d.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared agent profile management — loading, locking, and resolution.
|
|
3
|
-
* Used by both host.ts and server.ts.
|
|
4
|
-
*/
|
|
5
|
-
export interface AgentProfile {
|
|
6
|
-
apiKey: string;
|
|
7
|
-
agentId: string;
|
|
8
|
-
agentName: string;
|
|
9
|
-
registeredAt: string;
|
|
10
|
-
}
|
|
11
|
-
export declare const CANON_DIR: string;
|
|
12
|
-
export declare const AGENTS_PATH: string;
|
|
13
|
-
export declare const LOCKS_DIR: string;
|
|
14
|
-
export declare function loadProfiles(): Record<string, AgentProfile>;
|
|
15
|
-
export declare function isProcessAlive(pid: number): boolean;
|
|
16
|
-
export declare function isProfileLocked(profile: string): {
|
|
17
|
-
locked: boolean;
|
|
18
|
-
pid?: number;
|
|
19
|
-
};
|
|
20
|
-
export declare function acquireLock(profile: string): void;
|
|
21
|
-
export declare function releaseLock(profile: string): void;
|
package/dist/agent-profiles.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared agent profile management — loading, locking, and resolution.
|
|
3
|
-
* Used by both host.ts and server.ts.
|
|
4
|
-
*/
|
|
5
|
-
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
import { homedir } from 'node:os';
|
|
8
|
-
// ── Paths ────────────────────────────────────────────────────────────
|
|
9
|
-
export const CANON_DIR = join(homedir(), '.canon');
|
|
10
|
-
export const AGENTS_PATH = join(CANON_DIR, 'agents.json');
|
|
11
|
-
export const LOCKS_DIR = join(CANON_DIR, 'locks');
|
|
12
|
-
// ── Profile loading ──────────────────────────────────────────────────
|
|
13
|
-
export function loadProfiles() {
|
|
14
|
-
try {
|
|
15
|
-
return JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
return {};
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
// ── Process locking ──────────────────────────────────────────────────
|
|
22
|
-
export function isProcessAlive(pid) {
|
|
23
|
-
try {
|
|
24
|
-
process.kill(pid, 0);
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
export function isProfileLocked(profile) {
|
|
32
|
-
const lockPath = join(LOCKS_DIR, `${profile}.lock`);
|
|
33
|
-
try {
|
|
34
|
-
const pid = parseInt(readFileSync(lockPath, 'utf-8').trim());
|
|
35
|
-
if (isProcessAlive(pid))
|
|
36
|
-
return { locked: true, pid };
|
|
37
|
-
// Stale lock — clean it up
|
|
38
|
-
try {
|
|
39
|
-
unlinkSync(lockPath);
|
|
40
|
-
}
|
|
41
|
-
catch { }
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
// No lock file
|
|
45
|
-
}
|
|
46
|
-
return { locked: false };
|
|
47
|
-
}
|
|
48
|
-
export function acquireLock(profile) {
|
|
49
|
-
mkdirSync(LOCKS_DIR, { recursive: true });
|
|
50
|
-
const lockPath = join(LOCKS_DIR, `${profile}.lock`);
|
|
51
|
-
const check = isProfileLocked(profile);
|
|
52
|
-
if (check.locked) {
|
|
53
|
-
throw new Error(`Agent "${profile}" is in use by another session (PID ${check.pid})`);
|
|
54
|
-
}
|
|
55
|
-
writeFileSync(lockPath, String(process.pid));
|
|
56
|
-
}
|
|
57
|
-
export function releaseLock(profile) {
|
|
58
|
-
const lockPath = join(LOCKS_DIR, `${profile}.lock`);
|
|
59
|
-
try {
|
|
60
|
-
unlinkSync(lockPath);
|
|
61
|
-
}
|
|
62
|
-
catch { }
|
|
63
|
-
}
|
package/dist/rtdb-rest.d.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight RTDB REST helper for session state.
|
|
3
|
-
*
|
|
4
|
-
* Uses Firebase Realtime Database REST API to avoid adding firebase-admin
|
|
5
|
-
* as a dependency to the MCP plugin. The custom token from /agents/auth-token
|
|
6
|
-
* is exchanged for a Firebase ID token before use with RTDB REST.
|
|
7
|
-
*/
|
|
8
|
-
import { type CanonClient } from '@canonapp/core';
|
|
9
|
-
export interface SessionStatePayload {
|
|
10
|
-
model?: string;
|
|
11
|
-
permissionMode?: string;
|
|
12
|
-
effort?: string;
|
|
13
|
-
cwd?: string;
|
|
14
|
-
hostMode?: boolean;
|
|
15
|
-
isActive: boolean;
|
|
16
|
-
updatedAt: {
|
|
17
|
-
'.sv': 'timestamp';
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
/** Must be called once before any RTDB operations. */
|
|
21
|
-
export declare function initRTDBAuth(client: CanonClient): void;
|
|
22
|
-
/** Generic RTDB REST write (PUT). */
|
|
23
|
-
export declare function rtdbWrite(path: string, data: unknown): Promise<void>;
|
|
24
|
-
/** Generic RTDB REST read (GET). */
|
|
25
|
-
export declare function rtdbRead(path: string): Promise<unknown>;
|
|
26
|
-
/**
|
|
27
|
-
* Write session state to RTDB via REST API.
|
|
28
|
-
* Path: /session-state/{conversationId}/{agentId}
|
|
29
|
-
*/
|
|
30
|
-
export declare function writeSessionState(conversationId: string, agentId: string, state: Omit<SessionStatePayload, 'updatedAt'>): Promise<void>;
|
|
31
|
-
/**
|
|
32
|
-
* Clear session state from RTDB (full overwrite with isActive: false).
|
|
33
|
-
*/
|
|
34
|
-
export declare function clearSessionState(conversationId: string, agentId: string): Promise<void>;
|
package/dist/rtdb-rest.js
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight RTDB REST helper for session state.
|
|
3
|
-
*
|
|
4
|
-
* Uses Firebase Realtime Database REST API to avoid adding firebase-admin
|
|
5
|
-
* as a dependency to the MCP plugin. The custom token from /agents/auth-token
|
|
6
|
-
* is exchanged for a Firebase ID token before use with RTDB REST.
|
|
7
|
-
*/
|
|
8
|
-
import { DEFAULT_RTDB_URL, FIREBASE_WEB_API_KEY } from '@canonapp/core';
|
|
9
|
-
const RTDB_BASE = process.env.CANON_RTDB_URL || DEFAULT_RTDB_URL;
|
|
10
|
-
const FIREBASE_API_KEY = process.env.CANON_FIREBASE_API_KEY || FIREBASE_WEB_API_KEY;
|
|
11
|
-
// ── Token management ──────────────────────────────────────────────────
|
|
12
|
-
let cachedIdToken = null;
|
|
13
|
-
let idTokenExpiresAt = 0;
|
|
14
|
-
let tokenClient = null;
|
|
15
|
-
/** Must be called once before any RTDB operations. */
|
|
16
|
-
export function initRTDBAuth(client) {
|
|
17
|
-
tokenClient = client;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Exchange a Firebase custom token for an ID token via the Identity Toolkit API.
|
|
21
|
-
*/
|
|
22
|
-
async function exchangeCustomTokenForIdToken(customToken) {
|
|
23
|
-
const url = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${FIREBASE_API_KEY}`;
|
|
24
|
-
const res = await fetch(url, {
|
|
25
|
-
method: 'POST',
|
|
26
|
-
headers: { 'Content-Type': 'application/json' },
|
|
27
|
-
body: JSON.stringify({ token: customToken, returnSecureToken: true }),
|
|
28
|
-
});
|
|
29
|
-
if (!res.ok) {
|
|
30
|
-
const text = await res.text();
|
|
31
|
-
throw new Error(`Token exchange failed (${res.status}): ${text}`);
|
|
32
|
-
}
|
|
33
|
-
const data = await res.json();
|
|
34
|
-
return { idToken: data.idToken, expiresIn: parseInt(data.expiresIn, 10) };
|
|
35
|
-
}
|
|
36
|
-
/** Get a valid ID token for RTDB REST, refreshing if expired or expiring soon. */
|
|
37
|
-
async function getToken() {
|
|
38
|
-
// Refresh if missing, expired, or expiring within 5 minutes
|
|
39
|
-
if (!cachedIdToken || Date.now() > idTokenExpiresAt - 5 * 60 * 1000) {
|
|
40
|
-
if (!tokenClient)
|
|
41
|
-
return null;
|
|
42
|
-
try {
|
|
43
|
-
const auth = await tokenClient.getAuthToken();
|
|
44
|
-
const { idToken, expiresIn } = await exchangeCustomTokenForIdToken(auth.token);
|
|
45
|
-
cachedIdToken = idToken;
|
|
46
|
-
idTokenExpiresAt = Date.now() + expiresIn * 1000;
|
|
47
|
-
}
|
|
48
|
-
catch (err) {
|
|
49
|
-
console.error('[canon] RTDB token refresh failed:', err);
|
|
50
|
-
return cachedIdToken; // Return stale token as fallback
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return cachedIdToken;
|
|
54
|
-
}
|
|
55
|
-
// ── RTDB operations ───────────────────────────────────────────────────
|
|
56
|
-
/** Generic RTDB REST write (PUT). */
|
|
57
|
-
export async function rtdbWrite(path, data) {
|
|
58
|
-
const token = await getToken();
|
|
59
|
-
if (!token)
|
|
60
|
-
return;
|
|
61
|
-
const url = `${RTDB_BASE}${path}.json?auth=${token}`;
|
|
62
|
-
const res = await fetch(url, {
|
|
63
|
-
method: 'PUT',
|
|
64
|
-
headers: { 'Content-Type': 'application/json' },
|
|
65
|
-
body: JSON.stringify(data),
|
|
66
|
-
});
|
|
67
|
-
if (!res.ok) {
|
|
68
|
-
const text = await res.text();
|
|
69
|
-
throw new Error(`RTDB write failed (${res.status}): ${text}`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
/** Generic RTDB REST read (GET). */
|
|
73
|
-
export async function rtdbRead(path) {
|
|
74
|
-
const token = await getToken();
|
|
75
|
-
if (!token)
|
|
76
|
-
return null;
|
|
77
|
-
const url = `${RTDB_BASE}${path}.json?auth=${token}`;
|
|
78
|
-
const res = await fetch(url);
|
|
79
|
-
if (!res.ok)
|
|
80
|
-
return null;
|
|
81
|
-
return res.json();
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Write session state to RTDB via REST API.
|
|
85
|
-
* Path: /session-state/{conversationId}/{agentId}
|
|
86
|
-
*/
|
|
87
|
-
export async function writeSessionState(conversationId, agentId, state) {
|
|
88
|
-
const token = await getToken();
|
|
89
|
-
if (!token)
|
|
90
|
-
return;
|
|
91
|
-
const url = `${RTDB_BASE}/session-state/${conversationId}/${agentId}.json?auth=${token}`;
|
|
92
|
-
const body = {
|
|
93
|
-
...state,
|
|
94
|
-
updatedAt: { '.sv': 'timestamp' },
|
|
95
|
-
};
|
|
96
|
-
const res = await fetch(url, {
|
|
97
|
-
method: 'PUT',
|
|
98
|
-
headers: { 'Content-Type': 'application/json' },
|
|
99
|
-
body: JSON.stringify(body),
|
|
100
|
-
});
|
|
101
|
-
if (!res.ok) {
|
|
102
|
-
const text = await res.text();
|
|
103
|
-
throw new Error(`RTDB write failed (${res.status}): ${text}`);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Clear session state from RTDB (full overwrite with isActive: false).
|
|
108
|
-
*/
|
|
109
|
-
export async function clearSessionState(conversationId, agentId) {
|
|
110
|
-
const token = await getToken();
|
|
111
|
-
if (!token)
|
|
112
|
-
return;
|
|
113
|
-
const url = `${RTDB_BASE}/session-state/${conversationId}/${agentId}.json?auth=${token}`;
|
|
114
|
-
const body = {
|
|
115
|
-
isActive: false,
|
|
116
|
-
updatedAt: { '.sv': 'timestamp' },
|
|
117
|
-
};
|
|
118
|
-
// Use PUT (not PATCH) to clear stale fields like model/cwd
|
|
119
|
-
const res = await fetch(url, {
|
|
120
|
-
method: 'PUT',
|
|
121
|
-
headers: { 'Content-Type': 'application/json' },
|
|
122
|
-
body: JSON.stringify(body),
|
|
123
|
-
});
|
|
124
|
-
if (!res.ok) {
|
|
125
|
-
const text = await res.text();
|
|
126
|
-
console.error(`[canon] RTDB clear failed (${res.status}): ${text}`);
|
|
127
|
-
}
|
|
128
|
-
}
|