@canonmsg/claude-code-plugin 0.3.0 → 0.5.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 +426 -99
- package/dist/inbound-policy.d.ts +20 -0
- package/dist/inbound-policy.js +43 -0
- package/dist/register.js +0 -0
- package/dist/server.js +97 -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
|
@@ -23,32 +23,12 @@ setDefaultResultOrder('ipv4first');
|
|
|
23
23
|
*/
|
|
24
24
|
import { parseArgs } from 'node:util';
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
|
-
import { basename, resolve } from 'node:path';
|
|
27
26
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
28
|
-
import { CanonClient, CanonStream, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, } from '@canonmsg/core';
|
|
27
|
+
import { buildParticipationHistorySnapshot, buildBehaviorPolicyLines, buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildPublicWorkspaceOptions, isEnabledFlag, normalizeOptionalString, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, CanonClient, CanonStream, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, writeTurnState, releaseConversationEnvironment, } from '@canonmsg/core';
|
|
28
|
+
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
29
29
|
let workingDir = process.cwd();
|
|
30
30
|
let workspaceOptions = [];
|
|
31
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
32
|
function toModelOptions(models) {
|
|
53
33
|
return models.map((model) => ({
|
|
54
34
|
value: model.value,
|
|
@@ -65,32 +45,22 @@ async function publishAgentRuntime(agentId, runtime) {
|
|
|
65
45
|
}
|
|
66
46
|
async function loadSessionConfig(conversationId, agentId) {
|
|
67
47
|
const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
|
|
48
|
+
const baseConfig = readSessionWorkspaceConfig(raw);
|
|
68
49
|
if (!raw || typeof raw !== 'object')
|
|
69
|
-
return
|
|
50
|
+
return baseConfig;
|
|
70
51
|
const data = raw;
|
|
71
|
-
const permissionMode =
|
|
52
|
+
const permissionMode = normalizeOptionalString(data.permissionMode);
|
|
72
53
|
return {
|
|
73
|
-
|
|
74
|
-
legacyCwd: normalizeString(data.cwd),
|
|
75
|
-
model: normalizeString(data.model),
|
|
54
|
+
...baseConfig,
|
|
76
55
|
permissionMode: permissionMode,
|
|
77
56
|
};
|
|
78
57
|
}
|
|
79
58
|
function resolveWorkspaceCwd(config) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 }));
|
|
59
|
+
return resolveConfiguredWorkspaceCwd({
|
|
60
|
+
workspaceOptions,
|
|
61
|
+
config,
|
|
62
|
+
defaultCwd: workingDir,
|
|
63
|
+
});
|
|
94
64
|
}
|
|
95
65
|
async function detectRuntimeModels(cwd) {
|
|
96
66
|
const emptyPrompt = {
|
|
@@ -124,18 +94,25 @@ const STREAMING_THROTTLE_MS = 250;
|
|
|
124
94
|
const HEARTBEAT_MS = 30_000;
|
|
125
95
|
const IDLE_CHECK_MS = 60_000;
|
|
126
96
|
const CONTROL_POLL_MS = 2000;
|
|
97
|
+
const FINAL_DELIVERY_RETRY_MS = 2_000;
|
|
98
|
+
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
99
|
+
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
100
|
+
supportsInterrupt: true,
|
|
101
|
+
supportsQueue: true,
|
|
102
|
+
supportsInterleave: true,
|
|
103
|
+
supportsRequiresAction: true,
|
|
104
|
+
supportsNonFinalPermanentMessages: false,
|
|
105
|
+
};
|
|
127
106
|
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
107
|
return [
|
|
132
108
|
'You are connected to Canon messaging through a Claude Code host wrapper.',
|
|
133
|
-
'Reply naturally to the Canon user.',
|
|
134
109
|
'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
|
|
135
110
|
'Short intermediate assistant messages may be shown as ephemeral status while you work.',
|
|
136
|
-
|
|
111
|
+
...buildInboundContextLines(input.participantContext),
|
|
112
|
+
...buildBehaviorPolicyLines(input.behavior),
|
|
113
|
+
'Canon participants may be humans or AI agents.',
|
|
114
|
+
'Honor the Canon behavior policy above when deciding how proactively to participate.',
|
|
137
115
|
`Conversation ID: ${input.conversationId}`,
|
|
138
|
-
`Sender: ${input.senderName}`,
|
|
139
116
|
'',
|
|
140
117
|
'New Canon message:',
|
|
141
118
|
input.content,
|
|
@@ -143,7 +120,25 @@ function buildCanonPrompt(input) {
|
|
|
143
120
|
}
|
|
144
121
|
function renderInboundContent(message) {
|
|
145
122
|
let content = message.text || '';
|
|
146
|
-
|
|
123
|
+
const attachment = message.attachments?.[0];
|
|
124
|
+
if (attachment?.kind === 'audio' && attachment.url) {
|
|
125
|
+
const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
|
|
126
|
+
content = content
|
|
127
|
+
? `[Voice message${duration}: ${attachment.url}]\n${content}`
|
|
128
|
+
: `[Voice message${duration}: ${attachment.url}]`;
|
|
129
|
+
}
|
|
130
|
+
else if (attachment?.kind === 'image' && attachment.url) {
|
|
131
|
+
content = content
|
|
132
|
+
? `[Image: ${attachment.url}]\n${content}`
|
|
133
|
+
: `[Image: ${attachment.url}]`;
|
|
134
|
+
}
|
|
135
|
+
else if (attachment?.kind === 'file' && attachment.url) {
|
|
136
|
+
const label = attachment.fileName || 'File';
|
|
137
|
+
content = content
|
|
138
|
+
? `[File: ${label} ${attachment.url}]\n${content}`
|
|
139
|
+
: `[File: ${label} ${attachment.url}]`;
|
|
140
|
+
}
|
|
141
|
+
else if (message.contentType === 'audio' && message.audioUrl) {
|
|
147
142
|
const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
|
|
148
143
|
content = content
|
|
149
144
|
? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
|
|
@@ -157,7 +152,8 @@ function renderInboundContent(message) {
|
|
|
157
152
|
return content || '[Empty message]';
|
|
158
153
|
}
|
|
159
154
|
// ── Session factory ─────────────────────────────────────────────────
|
|
160
|
-
function createSession(conversationId,
|
|
155
|
+
function createSession(conversationId, environment, agentId, client, onSessionEnd, config, resumeSessionId) {
|
|
156
|
+
const { cwd } = environment;
|
|
161
157
|
// Per-session input stream with message queue
|
|
162
158
|
let resolveInput = null;
|
|
163
159
|
let inputDone = false;
|
|
@@ -173,6 +169,9 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
173
169
|
messageQueue.push(msg);
|
|
174
170
|
}
|
|
175
171
|
};
|
|
172
|
+
let enqueueInboundMessage = (msg, _intent = 'queue', _sourceMessageId, _markAccepted = false) => {
|
|
173
|
+
sendInput(msg);
|
|
174
|
+
};
|
|
176
175
|
const inputStream = {
|
|
177
176
|
[Symbol.asyncIterator]() {
|
|
178
177
|
return {
|
|
@@ -232,6 +231,8 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
232
231
|
type: 'claude_question',
|
|
233
232
|
questionId,
|
|
234
233
|
questions,
|
|
234
|
+
turnSemantics: 'control',
|
|
235
|
+
replyBehavior: 'suppress_auto_reply',
|
|
235
236
|
},
|
|
236
237
|
});
|
|
237
238
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Question sent (${questionId.slice(0, 8)})`);
|
|
@@ -249,7 +250,12 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
249
250
|
try {
|
|
250
251
|
const planId = randomUUID();
|
|
251
252
|
await client.sendMessage(conversationId, 'Plan ready for review.', {
|
|
252
|
-
metadata: {
|
|
253
|
+
metadata: {
|
|
254
|
+
type: 'plan_approval',
|
|
255
|
+
planId,
|
|
256
|
+
turnSemantics: 'control',
|
|
257
|
+
replyBehavior: 'suppress_auto_reply',
|
|
258
|
+
},
|
|
253
259
|
});
|
|
254
260
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
|
|
255
261
|
const reply = await waitForReply();
|
|
@@ -279,8 +285,10 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
279
285
|
session = {
|
|
280
286
|
conversationId,
|
|
281
287
|
cwd,
|
|
288
|
+
environment,
|
|
282
289
|
query: q,
|
|
283
290
|
sendInput,
|
|
291
|
+
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted),
|
|
284
292
|
state: {
|
|
285
293
|
model: config.model,
|
|
286
294
|
permissionMode: config.permissionMode,
|
|
@@ -289,7 +297,17 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
289
297
|
availableModels: config.availableModels,
|
|
290
298
|
streamingText: '',
|
|
291
299
|
streamingTimer: null,
|
|
300
|
+
idleResetTimer: null,
|
|
301
|
+
finalDeliveryTimer: null,
|
|
292
302
|
seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
|
|
303
|
+
pendingInputs: [],
|
|
304
|
+
pendingFinalText: null,
|
|
305
|
+
pendingFinalDeliveryText: null,
|
|
306
|
+
toolInProgress: false,
|
|
307
|
+
turnState: 'idle',
|
|
308
|
+
currentTurnId: null,
|
|
309
|
+
currentTurnOpenedAt: null,
|
|
310
|
+
lastAcceptedIntent: null,
|
|
293
311
|
lastActivity: Date.now(),
|
|
294
312
|
closed: false,
|
|
295
313
|
sdkSessionId: resumeSessionId || null,
|
|
@@ -322,15 +340,157 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
322
340
|
session.streamingText = '';
|
|
323
341
|
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
324
342
|
}
|
|
343
|
+
function clearIdleResetTimer() {
|
|
344
|
+
if (!session.idleResetTimer)
|
|
345
|
+
return;
|
|
346
|
+
clearTimeout(session.idleResetTimer);
|
|
347
|
+
session.idleResetTimer = null;
|
|
348
|
+
}
|
|
349
|
+
function clearFinalDeliveryTimer() {
|
|
350
|
+
if (!session.finalDeliveryTimer)
|
|
351
|
+
return;
|
|
352
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
353
|
+
session.finalDeliveryTimer = null;
|
|
354
|
+
}
|
|
355
|
+
function resetTurnToIdle() {
|
|
356
|
+
if (session.closed)
|
|
357
|
+
return;
|
|
358
|
+
clearFinalDeliveryTimer();
|
|
359
|
+
session.currentTurnId = null;
|
|
360
|
+
session.currentTurnOpenedAt = null;
|
|
361
|
+
session.lastAcceptedIntent = null;
|
|
362
|
+
session.pendingFinalText = null;
|
|
363
|
+
session.pendingFinalDeliveryText = null;
|
|
364
|
+
session.toolInProgress = false;
|
|
365
|
+
session.turnState = 'idle';
|
|
366
|
+
writeTurn();
|
|
367
|
+
drainPendingInput();
|
|
368
|
+
}
|
|
369
|
+
function scheduleFinalHandoffReset() {
|
|
370
|
+
clearIdleResetTimer();
|
|
371
|
+
session.idleResetTimer = setTimeout(() => {
|
|
372
|
+
session.idleResetTimer = null;
|
|
373
|
+
client.setTyping(conversationId, false).catch(() => { });
|
|
374
|
+
clearStreaming();
|
|
375
|
+
resetTurnToIdle();
|
|
376
|
+
}, FINAL_MESSAGE_HANDOFF_MS);
|
|
377
|
+
}
|
|
378
|
+
function markPendingFinalDelivery(finalText) {
|
|
379
|
+
clearIdleResetTimer();
|
|
380
|
+
session.pendingFinalText = finalText;
|
|
381
|
+
session.pendingFinalDeliveryText = finalText;
|
|
382
|
+
session.state.state = 'running';
|
|
383
|
+
session.turnState = 'streaming';
|
|
384
|
+
session.streamingText = finalText;
|
|
385
|
+
flushStreaming();
|
|
386
|
+
writeState();
|
|
387
|
+
writeTurn();
|
|
388
|
+
client.setTyping(conversationId, true, 'typing').catch(() => { });
|
|
389
|
+
}
|
|
390
|
+
async function deliverFinalReply(finalText) {
|
|
391
|
+
try {
|
|
392
|
+
await client.sendMessage(conversationId, finalText, {
|
|
393
|
+
metadata: {
|
|
394
|
+
turnId: session.currentTurnId,
|
|
395
|
+
turnSemantics: 'turn_complete',
|
|
396
|
+
turnComplete: true,
|
|
397
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
session.state.state = 'idle';
|
|
401
|
+
session.pendingFinalDeliveryText = null;
|
|
402
|
+
clearFinalDeliveryTimer();
|
|
403
|
+
writeState();
|
|
404
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
markPendingFinalDelivery(finalText);
|
|
409
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function scheduleFinalDeliveryRetry(finalText) {
|
|
414
|
+
if (session.closed || session.pendingFinalDeliveryText !== finalText)
|
|
415
|
+
return;
|
|
416
|
+
clearFinalDeliveryTimer();
|
|
417
|
+
session.finalDeliveryTimer = setTimeout(() => {
|
|
418
|
+
session.finalDeliveryTimer = null;
|
|
419
|
+
void deliverFinalReply(finalText).then((sent) => {
|
|
420
|
+
if (sent) {
|
|
421
|
+
scheduleFinalHandoffReset();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
scheduleFinalDeliveryRetry(finalText);
|
|
425
|
+
});
|
|
426
|
+
}, FINAL_DELIVERY_RETRY_MS);
|
|
427
|
+
}
|
|
325
428
|
function writeState() {
|
|
326
429
|
writeSessionState(conversationId, agentId, {
|
|
327
430
|
...session.state,
|
|
328
431
|
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
329
432
|
cwd,
|
|
433
|
+
executionMode: session.environment.mode,
|
|
434
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
330
435
|
hostMode: true,
|
|
331
436
|
isActive: true,
|
|
332
437
|
}).catch(() => { });
|
|
333
438
|
}
|
|
439
|
+
function writeTurn() {
|
|
440
|
+
writeTurnState(conversationId, agentId, {
|
|
441
|
+
turnId: session.currentTurnId,
|
|
442
|
+
state: session.turnState,
|
|
443
|
+
queueDepth: session.pendingInputs.length,
|
|
444
|
+
currentSpeakerId: agentId,
|
|
445
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
446
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
447
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
448
|
+
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
449
|
+
? { completedAt: { '.sv': 'timestamp' } }
|
|
450
|
+
: {}),
|
|
451
|
+
}).catch(() => { });
|
|
452
|
+
}
|
|
453
|
+
async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
|
|
454
|
+
if (!markAccepted || !sourceMessageId)
|
|
455
|
+
return;
|
|
456
|
+
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
457
|
+
}
|
|
458
|
+
function drainPendingInput() {
|
|
459
|
+
if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
|
|
460
|
+
return;
|
|
461
|
+
const next = session.pendingInputs.shift();
|
|
462
|
+
if (!next)
|
|
463
|
+
return;
|
|
464
|
+
session.lastAcceptedIntent = next.intent;
|
|
465
|
+
writeTurn();
|
|
466
|
+
void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
|
|
467
|
+
.finally(() => sendInput(next.msg));
|
|
468
|
+
}
|
|
469
|
+
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
|
|
470
|
+
session.lastActivity = Date.now();
|
|
471
|
+
if (session.pendingFinalDeliveryText) {
|
|
472
|
+
session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
|
|
473
|
+
writeTurn();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (session.state.state === 'running' && intent !== 'interrupt') {
|
|
477
|
+
session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
|
|
478
|
+
writeTurn();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (session.state.state === 'running' && intent === 'interrupt') {
|
|
482
|
+
session.pendingInputs.unshift({ msg, intent, sourceMessageId, markAccepted });
|
|
483
|
+
session.turnState = 'interrupted';
|
|
484
|
+
writeTurn();
|
|
485
|
+
void session.query.interrupt().catch(() => { });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
session.lastAcceptedIntent = intent;
|
|
489
|
+
writeTurn();
|
|
490
|
+
void markQueuedMessageAccepted(sourceMessageId, markAccepted)
|
|
491
|
+
.finally(() => sendInput(msg));
|
|
492
|
+
};
|
|
493
|
+
writeTurn();
|
|
334
494
|
// ── Process SDK output messages ──
|
|
335
495
|
const processMessages = async () => {
|
|
336
496
|
try {
|
|
@@ -351,21 +511,29 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
351
511
|
.filter((b) => b.type === 'text')
|
|
352
512
|
.map((b) => b.text);
|
|
353
513
|
const fullText = textBlocks.join('\n').trim();
|
|
354
|
-
clearStreaming();
|
|
355
514
|
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
|
-
}
|
|
515
|
+
session.pendingFinalText = fullText;
|
|
363
516
|
}
|
|
364
517
|
break;
|
|
365
518
|
}
|
|
366
519
|
case 'stream_event': {
|
|
367
520
|
const event = msg.event;
|
|
368
|
-
if (event?.type === '
|
|
521
|
+
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
522
|
+
session.toolInProgress = true;
|
|
523
|
+
session.turnState = 'tool';
|
|
524
|
+
writeTurn();
|
|
525
|
+
}
|
|
526
|
+
else if (event?.type === 'content_block_stop' && session.toolInProgress) {
|
|
527
|
+
session.toolInProgress = false;
|
|
528
|
+
if (session.state.state === 'running') {
|
|
529
|
+
session.turnState = 'thinking';
|
|
530
|
+
writeTurn();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
else if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
534
|
+
session.toolInProgress = false;
|
|
535
|
+
session.turnState = 'streaming';
|
|
536
|
+
writeTurn();
|
|
369
537
|
onStreamDelta(event.delta.text);
|
|
370
538
|
}
|
|
371
539
|
break;
|
|
@@ -376,16 +544,32 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
376
544
|
const state = msg.state;
|
|
377
545
|
session.state.state = state;
|
|
378
546
|
if (state === 'running') {
|
|
547
|
+
clearIdleResetTimer();
|
|
548
|
+
if (!session.currentTurnId) {
|
|
549
|
+
session.currentTurnId = randomUUID();
|
|
550
|
+
session.currentTurnOpenedAt = Date.now();
|
|
551
|
+
}
|
|
552
|
+
session.state.contextUsage = undefined;
|
|
553
|
+
session.pendingFinalText = null;
|
|
554
|
+
session.toolInProgress = false;
|
|
555
|
+
session.turnState = 'thinking';
|
|
379
556
|
client.setTyping(conversationId, true, 'thinking').catch(() => { });
|
|
380
557
|
}
|
|
381
558
|
else if (state === 'idle') {
|
|
382
559
|
client.setTyping(conversationId, false).catch(() => { });
|
|
383
|
-
clearStreaming();
|
|
384
560
|
}
|
|
385
561
|
else if (state === 'requires_action') {
|
|
562
|
+
clearIdleResetTimer();
|
|
563
|
+
if (!session.currentTurnId) {
|
|
564
|
+
session.currentTurnId = randomUUID();
|
|
565
|
+
session.currentTurnOpenedAt = Date.now();
|
|
566
|
+
}
|
|
567
|
+
session.toolInProgress = false;
|
|
568
|
+
session.turnState = 'waiting_input';
|
|
386
569
|
client.setTyping(conversationId, false).catch(() => { });
|
|
387
570
|
}
|
|
388
571
|
writeState();
|
|
572
|
+
writeTurn();
|
|
389
573
|
}
|
|
390
574
|
break;
|
|
391
575
|
}
|
|
@@ -395,24 +579,30 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
395
579
|
if (resultSessionId)
|
|
396
580
|
session.sdkSessionId = resultSessionId;
|
|
397
581
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
|
|
582
|
+
const resultText = typeof msg.result === 'string'
|
|
583
|
+
? msg.result.trim()
|
|
584
|
+
: '';
|
|
585
|
+
const finalText = resultText || session.pendingFinalText?.trim() || null;
|
|
586
|
+
const finalDelivered = finalText ? await deliverFinalReply(finalText) : true;
|
|
398
587
|
try {
|
|
399
588
|
const usage = await q.getContextUsage();
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
},
|
|
408
|
-
cwd,
|
|
409
|
-
hostMode: true,
|
|
410
|
-
isActive: true,
|
|
411
|
-
}).catch(() => { });
|
|
589
|
+
session.state.contextUsage = {
|
|
590
|
+
percentage: usage.percentage,
|
|
591
|
+
totalTokens: usage.totalTokens,
|
|
592
|
+
maxTokens: usage.maxTokens,
|
|
593
|
+
};
|
|
594
|
+
session.state.state = finalDelivered ? 'idle' : session.state.state;
|
|
595
|
+
writeState();
|
|
412
596
|
}
|
|
413
597
|
catch {
|
|
414
598
|
// Non-fatal
|
|
415
599
|
}
|
|
600
|
+
if (finalDelivered) {
|
|
601
|
+
scheduleFinalHandoffReset();
|
|
602
|
+
}
|
|
603
|
+
else if (finalText) {
|
|
604
|
+
scheduleFinalDeliveryRetry(finalText);
|
|
605
|
+
}
|
|
416
606
|
break;
|
|
417
607
|
}
|
|
418
608
|
default:
|
|
@@ -448,7 +638,7 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
448
638
|
type: 'user',
|
|
449
639
|
message: {
|
|
450
640
|
role: 'user',
|
|
451
|
-
content: 'You are connected to Canon messaging.
|
|
641
|
+
content: 'You are connected to Canon messaging. Follow-up messages in this session come from Canon participants, and your text responses will be sent back to the conversation. You have full Claude Code capabilities (file read/write, bash, etc).',
|
|
452
642
|
},
|
|
453
643
|
parent_tool_use_id: null,
|
|
454
644
|
});
|
|
@@ -464,12 +654,14 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
464
654
|
defaultModel: session.state.model ?? modelList[0]?.value,
|
|
465
655
|
defaultPermissionMode: 'default',
|
|
466
656
|
availableModels: modelList,
|
|
467
|
-
availableWorkspaces:
|
|
657
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
468
658
|
}).catch(() => { });
|
|
469
659
|
writeSessionState(conversationId, agentId, {
|
|
470
660
|
...session.state,
|
|
471
661
|
availableModels: modelList,
|
|
472
662
|
cwd,
|
|
663
|
+
executionMode: session.environment.mode,
|
|
664
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
473
665
|
hostMode: true,
|
|
474
666
|
isActive: true,
|
|
475
667
|
}).catch(() => { });
|
|
@@ -478,7 +670,8 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
478
670
|
catch {
|
|
479
671
|
writeState();
|
|
480
672
|
}
|
|
481
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${resumeSessionId ? 'resumed' : 'started'}
|
|
673
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${resumeSessionId ? 'resumed' : 'started'} `
|
|
674
|
+
+ `(mode: ${environment.mode}, cwd: ${cwd})`);
|
|
482
675
|
};
|
|
483
676
|
init().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err));
|
|
484
677
|
return session;
|
|
@@ -489,12 +682,17 @@ async function main() {
|
|
|
489
682
|
options: {
|
|
490
683
|
cwd: { type: 'string' },
|
|
491
684
|
workspace: { type: 'string', multiple: true },
|
|
685
|
+
'enable-worktrees': { type: 'boolean' },
|
|
492
686
|
},
|
|
493
687
|
strict: false,
|
|
494
688
|
});
|
|
495
689
|
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
496
690
|
const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
|
|
497
|
-
workspaceOptions =
|
|
691
|
+
workspaceOptions = buildConfiguredWorkspaceOptions(workingDir, configuredWorkspaces);
|
|
692
|
+
const allowWorktrees = isEnabledFlag(args['enable-worktrees'] ?? process.env.CANON_ENABLE_WORKTREES);
|
|
693
|
+
if (!allowWorktrees) {
|
|
694
|
+
console.error('[canon-host] Worktree isolation is disabled; sessions will lock their selected workspace unless explicitly enabled.');
|
|
695
|
+
}
|
|
498
696
|
const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-host]' });
|
|
499
697
|
console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
500
698
|
// ── Canon API client + RTDB auth ──
|
|
@@ -526,7 +724,7 @@ async function main() {
|
|
|
526
724
|
defaultModel: runtimeModels[0]?.value,
|
|
527
725
|
defaultPermissionMode: 'default',
|
|
528
726
|
availableModels: runtimeModels,
|
|
529
|
-
availableWorkspaces:
|
|
727
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
530
728
|
});
|
|
531
729
|
}
|
|
532
730
|
catch (error) {
|
|
@@ -542,21 +740,77 @@ async function main() {
|
|
|
542
740
|
// ── Session manager ──
|
|
543
741
|
const sessions = new Map();
|
|
544
742
|
const pendingSessionCreations = new Map();
|
|
545
|
-
/** Saved SDK session IDs for resume after idle eviction */
|
|
743
|
+
/** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
|
|
546
744
|
const savedSessionIds = new Map();
|
|
745
|
+
const conversationCache = new Map();
|
|
746
|
+
let conversationCacheLoadedAt = 0;
|
|
747
|
+
async function refreshConversationCache(force = false) {
|
|
748
|
+
if (!force && conversationCache.size > 0 && Date.now() - conversationCacheLoadedAt < 10_000) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const conversations = await client.getConversations();
|
|
752
|
+
conversationCache.clear();
|
|
753
|
+
for (const conversation of conversations) {
|
|
754
|
+
conversationCache.set(conversation.id, conversation);
|
|
755
|
+
}
|
|
756
|
+
conversationCacheLoadedAt = Date.now();
|
|
757
|
+
}
|
|
758
|
+
async function getConversationMeta(conversationId) {
|
|
759
|
+
try {
|
|
760
|
+
await refreshConversationCache();
|
|
761
|
+
const cached = conversationCache.get(conversationId);
|
|
762
|
+
if (cached)
|
|
763
|
+
return cached;
|
|
764
|
+
await refreshConversationCache(true);
|
|
765
|
+
return conversationCache.get(conversationId) ?? null;
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
return conversationCache.get(conversationId) ?? null;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
async function loadParticipantContext(input) {
|
|
772
|
+
const [conversation, recentMessages] = await Promise.all([
|
|
773
|
+
getConversationMeta(input.conversationId),
|
|
774
|
+
client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
|
|
775
|
+
]);
|
|
776
|
+
const history = buildParticipationHistorySnapshot(recentMessages, agentId);
|
|
777
|
+
return {
|
|
778
|
+
conversationType: conversation?.type ?? 'unknown',
|
|
779
|
+
memberCount: conversation?.memberIds?.length ?? null,
|
|
780
|
+
senderType: input.message.senderType ?? 'human',
|
|
781
|
+
senderName: input.senderName,
|
|
782
|
+
isOwner: input.isOwner,
|
|
783
|
+
mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
|
|
784
|
+
recentSenderTypes: history.recentSenderTypes,
|
|
785
|
+
recentHumanCount: history.recentHumanCount,
|
|
786
|
+
recentAgentCount: history.recentAgentCount,
|
|
787
|
+
consecutiveAgentTurns: history.consecutiveAgentTurns,
|
|
788
|
+
currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
547
791
|
function closeSession(conversationId) {
|
|
548
792
|
const session = sessions.get(conversationId);
|
|
549
793
|
if (!session)
|
|
550
794
|
return;
|
|
551
795
|
session.closed = true;
|
|
796
|
+
releaseConversationEnvironment(session.environment);
|
|
552
797
|
// Save SDK session ID for future resume
|
|
553
798
|
if (session.sdkSessionId) {
|
|
554
|
-
savedSessionIds.set(conversationId, session.sdkSessionId);
|
|
799
|
+
savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
|
|
555
800
|
}
|
|
556
801
|
if (session.streamingTimer)
|
|
557
802
|
clearTimeout(session.streamingTimer);
|
|
803
|
+
if (session.idleResetTimer) {
|
|
804
|
+
clearTimeout(session.idleResetTimer);
|
|
805
|
+
session.idleResetTimer = null;
|
|
806
|
+
}
|
|
807
|
+
if (session.finalDeliveryTimer) {
|
|
808
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
809
|
+
session.finalDeliveryTimer = null;
|
|
810
|
+
}
|
|
558
811
|
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
559
812
|
clearSessionState(conversationId, agentId).catch(() => { });
|
|
813
|
+
clearTurnState(conversationId, agentId).catch(() => { });
|
|
560
814
|
client.setTyping(conversationId, false).catch(() => { });
|
|
561
815
|
try {
|
|
562
816
|
session.query.close();
|
|
@@ -593,20 +847,40 @@ async function main() {
|
|
|
593
847
|
}
|
|
594
848
|
const creation = (async () => {
|
|
595
849
|
const config = await loadSessionConfig(conversationId, agentId);
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
850
|
+
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
851
|
+
const environment = prepareConversationEnvironment({
|
|
852
|
+
agentId,
|
|
853
|
+
conversationId,
|
|
854
|
+
workspaceCwd,
|
|
855
|
+
allowWorktrees,
|
|
856
|
+
});
|
|
857
|
+
try {
|
|
858
|
+
const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
|
|
859
|
+
const resumeId = savedSessionIds.get(resumeKey);
|
|
860
|
+
const session = createSession(conversationId, environment, agentId, client, (convoId, sdkSessionId) => {
|
|
861
|
+
// Called when session processing loop ends — save session ID for resume
|
|
862
|
+
const existing = sessions.get(convoId);
|
|
863
|
+
if (existing) {
|
|
864
|
+
releaseConversationEnvironment(existing.environment);
|
|
865
|
+
if (sdkSessionId) {
|
|
866
|
+
savedSessionIds.set(buildConversationEnvironmentKey(convoId, existing.environment.baseCwd), sdkSessionId);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
sessions.delete(convoId);
|
|
870
|
+
clearSessionState(convoId, agentId).catch(() => { });
|
|
871
|
+
clearTurnState(convoId, agentId).catch(() => { });
|
|
872
|
+
}, {
|
|
873
|
+
model: config?.model ?? runtimeModels[0]?.value,
|
|
874
|
+
permissionMode: config?.permissionMode ?? 'default',
|
|
875
|
+
availableModels: runtimeModels,
|
|
876
|
+
}, resumeId);
|
|
877
|
+
sessions.set(conversationId, session);
|
|
878
|
+
return session;
|
|
879
|
+
}
|
|
880
|
+
catch (error) {
|
|
881
|
+
releaseConversationEnvironment(environment);
|
|
882
|
+
throw error;
|
|
883
|
+
}
|
|
610
884
|
})();
|
|
611
885
|
pendingSessionCreations.set(conversationId, creation);
|
|
612
886
|
try {
|
|
@@ -641,22 +915,48 @@ async function main() {
|
|
|
641
915
|
const sender = m.senderName || m.senderId;
|
|
642
916
|
const content = renderInboundContent(m);
|
|
643
917
|
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
918
|
void (async () => {
|
|
646
|
-
const
|
|
647
|
-
|
|
919
|
+
const conversation = await getConversationMeta(convoId);
|
|
920
|
+
const behavior = payload.behavior ?? conversation?.behavior;
|
|
921
|
+
const participantContext = await loadParticipantContext({
|
|
922
|
+
conversationId: convoId,
|
|
923
|
+
message: m,
|
|
924
|
+
senderName: sender,
|
|
925
|
+
isOwner,
|
|
926
|
+
});
|
|
927
|
+
const autoReply = decideAutoReply(participantContext, behavior);
|
|
928
|
+
if (!autoReply.allow) {
|
|
929
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
933
|
+
let session;
|
|
934
|
+
try {
|
|
935
|
+
session = await getOrCreateSession(convoId);
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
939
|
+
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
940
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
941
|
+
await client.sendMessage(convoId, `I couldn't start a coding session for this workspace: ${userMessage}`).catch(() => { });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
945
|
+
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
946
|
+
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
947
|
+
session.enqueueInbound({
|
|
648
948
|
type: 'user',
|
|
649
949
|
message: {
|
|
650
950
|
role: 'user',
|
|
651
951
|
content: buildCanonPrompt({
|
|
652
952
|
content,
|
|
653
953
|
conversationId: convoId,
|
|
654
|
-
|
|
655
|
-
|
|
954
|
+
participantContext,
|
|
955
|
+
behavior,
|
|
656
956
|
}),
|
|
657
957
|
},
|
|
658
958
|
parent_tool_use_id: null,
|
|
659
|
-
});
|
|
959
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
|
660
960
|
})();
|
|
661
961
|
},
|
|
662
962
|
onConnected: () => console.error('[canon-host] SSE connected'),
|
|
@@ -716,6 +1016,8 @@ async function main() {
|
|
|
716
1016
|
...session.state,
|
|
717
1017
|
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
718
1018
|
cwd: session.cwd,
|
|
1019
|
+
executionMode: session.environment.mode,
|
|
1020
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
719
1021
|
hostMode: true,
|
|
720
1022
|
isActive: true,
|
|
721
1023
|
}).catch(() => { });
|
|
@@ -733,6 +1035,17 @@ async function main() {
|
|
|
733
1035
|
if (session && !session.closed) {
|
|
734
1036
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Interrupt signal`);
|
|
735
1037
|
try {
|
|
1038
|
+
session.turnState = 'interrupted';
|
|
1039
|
+
writeTurnState(convoId, agentId, {
|
|
1040
|
+
turnId: session.currentTurnId,
|
|
1041
|
+
state: 'interrupted',
|
|
1042
|
+
queueDepth: session.pendingInputs.length,
|
|
1043
|
+
currentSpeakerId: agentId,
|
|
1044
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
1045
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1046
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
1047
|
+
completedAt: { '.sv': 'timestamp' },
|
|
1048
|
+
}).catch(() => { });
|
|
736
1049
|
await session.query.interrupt();
|
|
737
1050
|
if (session.streamingTimer) {
|
|
738
1051
|
clearTimeout(session.streamingTimer);
|
|
@@ -764,9 +1077,23 @@ async function main() {
|
|
|
764
1077
|
...session.state,
|
|
765
1078
|
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
766
1079
|
cwd: session.cwd,
|
|
1080
|
+
executionMode: session.environment.mode,
|
|
1081
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
767
1082
|
hostMode: true,
|
|
768
1083
|
isActive: true,
|
|
769
1084
|
}).catch(() => { });
|
|
1085
|
+
writeTurnState(session.conversationId, agentId, {
|
|
1086
|
+
turnId: session.currentTurnId,
|
|
1087
|
+
state: session.turnState,
|
|
1088
|
+
queueDepth: session.pendingInputs.length,
|
|
1089
|
+
currentSpeakerId: agentId,
|
|
1090
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
1091
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1092
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
1093
|
+
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
1094
|
+
? { completedAt: { '.sv': 'timestamp' } }
|
|
1095
|
+
: {}),
|
|
1096
|
+
}).catch(() => { });
|
|
770
1097
|
}
|
|
771
1098
|
}, HEARTBEAT_MS);
|
|
772
1099
|
// ── Idle session cleanup ──
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type CanonConversation, type ResolvedAgentBehaviorPolicy } 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
|
+
currentAgentStreakStartedByHuman: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface AutoReplyDecision {
|
|
16
|
+
allow: boolean;
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
|
|
20
|
+
export declare function decideAutoReply(context: InboundParticipantContext, behavior?: ResolvedAgentBehaviorPolicy | null): AutoReplyDecision;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
|
|
2
|
+
function formatRecentSenders(senderTypes) {
|
|
3
|
+
if (senderTypes.length === 0)
|
|
4
|
+
return 'none';
|
|
5
|
+
return senderTypes.map((senderType) => (senderType === 'ai_agent' ? 'agent' : 'human')).join(' -> ');
|
|
6
|
+
}
|
|
7
|
+
export function buildInboundContextLines(context) {
|
|
8
|
+
const conversationTypeLabel = context.conversationType === 'unknown'
|
|
9
|
+
? 'unknown'
|
|
10
|
+
: `${context.conversationType}${context.memberCount ? ` (${context.memberCount} members)` : ''}`;
|
|
11
|
+
const senderRole = context.isOwner
|
|
12
|
+
? 'The latest sender is the verified human owner of this Canon agent.'
|
|
13
|
+
: context.senderType === 'ai_agent'
|
|
14
|
+
? 'The latest sender is another AI agent in Canon.'
|
|
15
|
+
: 'The latest sender is a human Canon participant.';
|
|
16
|
+
return [
|
|
17
|
+
senderRole,
|
|
18
|
+
`Latest sender name: ${context.senderName}`,
|
|
19
|
+
`Latest sender type: ${context.senderType}`,
|
|
20
|
+
`Conversation type: ${conversationTypeLabel}`,
|
|
21
|
+
`Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
|
|
22
|
+
`Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
|
|
23
|
+
`Recent human messages: ${context.recentHumanCount}`,
|
|
24
|
+
`Recent agent messages: ${context.recentAgentCount}`,
|
|
25
|
+
`Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
|
|
26
|
+
`Current agent streak started after a human message: ${context.currentAgentStreakStartedByHuman ? 'yes' : 'no'}`,
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
export function decideAutoReply(context, behavior) {
|
|
30
|
+
const decision = evaluateParticipationPolicy(behavior ?? resolveAgentBehaviorPolicy(), {
|
|
31
|
+
conversationType: context.conversationType,
|
|
32
|
+
senderType: context.senderType,
|
|
33
|
+
isOwner: context.isOwner,
|
|
34
|
+
mentionedAgent: context.mentionedAgent,
|
|
35
|
+
recentHumanCount: context.recentHumanCount,
|
|
36
|
+
consecutiveAgentTurns: context.consecutiveAgentTurns,
|
|
37
|
+
currentAgentStreakStartedByHuman: context.currentAgentStreakStartedByHuman,
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
allow: decision.allow,
|
|
41
|
+
reason: decision.reason,
|
|
42
|
+
};
|
|
43
|
+
}
|
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,36 @@ async function handleInboundMessage(payload) {
|
|
|
261
325
|
content_type: m.contentType || 'text',
|
|
262
326
|
ts: new Date().toISOString(),
|
|
263
327
|
};
|
|
328
|
+
if (payload.behavior) {
|
|
329
|
+
meta.behavior_style = payload.behavior.participation.style;
|
|
330
|
+
meta.behavior_allow_agent_to_agent = String(payload.behavior.participation.allowAgentToAgent);
|
|
331
|
+
meta.behavior_allow_long_running = String(payload.behavior.participation.allowLongRunningCollaboration);
|
|
332
|
+
meta.behavior_require_group_mention = String(payload.behavior.participation.requireMentionForGroupReplies);
|
|
333
|
+
if (payload.behavior.participation.maxConsecutiveAgentTurns != null) {
|
|
334
|
+
meta.behavior_max_consecutive_agent_turns = String(payload.behavior.participation.maxConsecutiveAgentTurns);
|
|
335
|
+
}
|
|
336
|
+
if (payload.behavior.instructions.length > 0) {
|
|
337
|
+
meta.behavior_instructions = payload.behavior.instructions.join('\n\n');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const primaryAttachment = getPrimaryAttachment(m);
|
|
264
341
|
if (m.imageUrl)
|
|
265
342
|
meta.image_url = m.imageUrl;
|
|
266
343
|
if (m.audioUrl)
|
|
267
344
|
meta.audio_url = m.audioUrl;
|
|
268
|
-
|
|
345
|
+
if (primaryAttachment?.url) {
|
|
346
|
+
meta.attachment_kind = primaryAttachment.kind;
|
|
347
|
+
meta.attachment_url = primaryAttachment.url;
|
|
348
|
+
}
|
|
349
|
+
if (primaryAttachment?.fileName)
|
|
350
|
+
meta.attachment_file_name = primaryAttachment.fileName;
|
|
351
|
+
const renderedContent = renderInboundContent(m);
|
|
352
|
+
console.error(`[canon] Inbound message from ${meta.sender_name}: "${renderedContent}"`);
|
|
269
353
|
try {
|
|
270
354
|
await server.notification({
|
|
271
355
|
method: 'notifications/claude/channel',
|
|
272
356
|
params: {
|
|
273
|
-
content:
|
|
357
|
+
content: renderedContent,
|
|
274
358
|
meta,
|
|
275
359
|
},
|
|
276
360
|
});
|
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.5.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.4.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
|
-
}
|