@canonmsg/claude-code-plugin 0.4.0 → 0.6.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 +220 -130
- package/dist/inbound-policy.d.ts +3 -2
- package/dist/inbound-policy.js +16 -23
- package/dist/register.js +9 -0
- package/dist/server.js +12 -0
- package/package.json +2 -2
package/dist/host.js
CHANGED
|
@@ -23,33 +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, clearTurnState, DEFAULT_RUNTIME_CAPABILITIES, normalizeTurnMetadata, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, writeTurnState, } 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';
|
|
29
28
|
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
30
29
|
let workingDir = process.cwd();
|
|
31
30
|
let workspaceOptions = [];
|
|
32
31
|
let runtimeModels = [];
|
|
33
|
-
function normalizeString(value) {
|
|
34
|
-
if (typeof value !== 'string')
|
|
35
|
-
return undefined;
|
|
36
|
-
const trimmed = value.trim();
|
|
37
|
-
return trimmed ? trimmed : undefined;
|
|
38
|
-
}
|
|
39
|
-
function buildWorkspaceOptions(primaryCwd, configured) {
|
|
40
|
-
const uniqueDirs = Array.from(new Set([primaryCwd, ...configured].map((dir) => resolve(dir))));
|
|
41
|
-
const seenLabels = new Map();
|
|
42
|
-
return uniqueDirs.map((cwd, index) => {
|
|
43
|
-
const baseLabel = basename(cwd) || cwd;
|
|
44
|
-
const seenCount = (seenLabels.get(baseLabel) ?? 0) + 1;
|
|
45
|
-
seenLabels.set(baseLabel, seenCount);
|
|
46
|
-
return {
|
|
47
|
-
id: index === 0 ? 'default' : `workspace-${index + 1}`,
|
|
48
|
-
label: seenCount === 1 ? baseLabel : `${baseLabel} (${seenCount})`,
|
|
49
|
-
cwd,
|
|
50
|
-
};
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
32
|
function toModelOptions(models) {
|
|
54
33
|
return models.map((model) => ({
|
|
55
34
|
value: model.value,
|
|
@@ -66,32 +45,22 @@ async function publishAgentRuntime(agentId, runtime) {
|
|
|
66
45
|
}
|
|
67
46
|
async function loadSessionConfig(conversationId, agentId) {
|
|
68
47
|
const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
|
|
48
|
+
const baseConfig = readSessionWorkspaceConfig(raw);
|
|
69
49
|
if (!raw || typeof raw !== 'object')
|
|
70
|
-
return
|
|
50
|
+
return baseConfig;
|
|
71
51
|
const data = raw;
|
|
72
|
-
const permissionMode =
|
|
52
|
+
const permissionMode = normalizeOptionalString(data.permissionMode);
|
|
73
53
|
return {
|
|
74
|
-
|
|
75
|
-
legacyCwd: normalizeString(data.cwd),
|
|
76
|
-
model: normalizeString(data.model),
|
|
54
|
+
...baseConfig,
|
|
77
55
|
permissionMode: permissionMode,
|
|
78
56
|
};
|
|
79
57
|
}
|
|
80
58
|
function resolveWorkspaceCwd(config) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
if (config?.legacyCwd) {
|
|
87
|
-
const workspace = workspaceOptions.find((option) => option.cwd === config.legacyCwd);
|
|
88
|
-
if (workspace)
|
|
89
|
-
return workspace.cwd;
|
|
90
|
-
}
|
|
91
|
-
return workspaceOptions[0]?.cwd ?? workingDir;
|
|
92
|
-
}
|
|
93
|
-
function toPublicWorkspaceOptions() {
|
|
94
|
-
return workspaceOptions.map(({ id, label }) => ({ id, label }));
|
|
59
|
+
return resolveConfiguredWorkspaceCwd({
|
|
60
|
+
workspaceOptions,
|
|
61
|
+
config,
|
|
62
|
+
defaultCwd: workingDir,
|
|
63
|
+
});
|
|
95
64
|
}
|
|
96
65
|
async function detectRuntimeModels(cwd) {
|
|
97
66
|
const emptyPrompt = {
|
|
@@ -125,24 +94,24 @@ const STREAMING_THROTTLE_MS = 250;
|
|
|
125
94
|
const HEARTBEAT_MS = 30_000;
|
|
126
95
|
const IDLE_CHECK_MS = 60_000;
|
|
127
96
|
const CONTROL_POLL_MS = 2000;
|
|
128
|
-
const
|
|
97
|
+
const FINAL_DELIVERY_RETRY_MS = 2_000;
|
|
129
98
|
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
130
99
|
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
131
100
|
supportsInterrupt: true,
|
|
132
101
|
supportsQueue: true,
|
|
133
102
|
supportsInterleave: true,
|
|
134
103
|
supportsRequiresAction: true,
|
|
135
|
-
supportsNonFinalPermanentMessages:
|
|
104
|
+
supportsNonFinalPermanentMessages: false,
|
|
136
105
|
};
|
|
137
106
|
function buildCanonPrompt(input) {
|
|
138
107
|
return [
|
|
139
108
|
'You are connected to Canon messaging through a Claude Code host wrapper.',
|
|
140
|
-
'Reply naturally to Canon participants.',
|
|
141
109
|
'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
|
|
142
110
|
'Short intermediate assistant messages may be shown as ephemeral status while you work.',
|
|
143
111
|
...buildInboundContextLines(input.participantContext),
|
|
112
|
+
...buildBehaviorPolicyLines(input.behavior),
|
|
144
113
|
'Canon participants may be humans or AI agents.',
|
|
145
|
-
'
|
|
114
|
+
'Honor the Canon behavior policy above when deciding how proactively to participate.',
|
|
146
115
|
`Conversation ID: ${input.conversationId}`,
|
|
147
116
|
'',
|
|
148
117
|
'New Canon message:',
|
|
@@ -183,7 +152,8 @@ function renderInboundContent(message) {
|
|
|
183
152
|
return content || '[Empty message]';
|
|
184
153
|
}
|
|
185
154
|
// ── Session factory ─────────────────────────────────────────────────
|
|
186
|
-
function createSession(conversationId,
|
|
155
|
+
function createSession(conversationId, environment, agentId, client, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
|
|
156
|
+
const { cwd } = environment;
|
|
187
157
|
// Per-session input stream with message queue
|
|
188
158
|
let resolveInput = null;
|
|
189
159
|
let inputDone = false;
|
|
@@ -315,6 +285,7 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
315
285
|
session = {
|
|
316
286
|
conversationId,
|
|
317
287
|
cwd,
|
|
288
|
+
environment,
|
|
318
289
|
query: q,
|
|
319
290
|
sendInput,
|
|
320
291
|
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted),
|
|
@@ -327,9 +298,11 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
327
298
|
streamingText: '',
|
|
328
299
|
streamingTimer: null,
|
|
329
300
|
idleResetTimer: null,
|
|
301
|
+
finalDeliveryTimer: null,
|
|
330
302
|
seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
|
|
331
303
|
pendingInputs: [],
|
|
332
304
|
pendingFinalText: null,
|
|
305
|
+
pendingFinalDeliveryText: null,
|
|
333
306
|
toolInProgress: false,
|
|
334
307
|
turnState: 'idle',
|
|
335
308
|
currentTurnId: null,
|
|
@@ -373,30 +346,92 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
373
346
|
clearTimeout(session.idleResetTimer);
|
|
374
347
|
session.idleResetTimer = null;
|
|
375
348
|
}
|
|
349
|
+
function clearFinalDeliveryTimer() {
|
|
350
|
+
if (!session.finalDeliveryTimer)
|
|
351
|
+
return;
|
|
352
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
353
|
+
session.finalDeliveryTimer = null;
|
|
354
|
+
}
|
|
376
355
|
function resetTurnToIdle() {
|
|
377
356
|
if (session.closed)
|
|
378
357
|
return;
|
|
358
|
+
clearFinalDeliveryTimer();
|
|
379
359
|
session.currentTurnId = null;
|
|
380
360
|
session.currentTurnOpenedAt = null;
|
|
381
361
|
session.lastAcceptedIntent = null;
|
|
382
362
|
session.pendingFinalText = null;
|
|
363
|
+
session.pendingFinalDeliveryText = null;
|
|
383
364
|
session.toolInProgress = false;
|
|
384
365
|
session.turnState = 'idle';
|
|
385
366
|
writeTurn();
|
|
386
367
|
drainPendingInput();
|
|
387
368
|
}
|
|
388
|
-
function
|
|
369
|
+
function scheduleFinalHandoffReset() {
|
|
389
370
|
clearIdleResetTimer();
|
|
390
371
|
session.idleResetTimer = setTimeout(() => {
|
|
391
372
|
session.idleResetTimer = null;
|
|
373
|
+
client.setTyping(conversationId, false).catch(() => { });
|
|
374
|
+
clearStreaming();
|
|
392
375
|
resetTurnToIdle();
|
|
393
|
-
},
|
|
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);
|
|
394
427
|
}
|
|
395
428
|
function writeState() {
|
|
396
429
|
writeSessionState(conversationId, agentId, {
|
|
397
430
|
...session.state,
|
|
398
431
|
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
399
432
|
cwd,
|
|
433
|
+
executionMode: session.environment.mode,
|
|
434
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
400
435
|
hostMode: true,
|
|
401
436
|
isActive: true,
|
|
402
437
|
}).catch(() => { });
|
|
@@ -433,6 +468,11 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
433
468
|
}
|
|
434
469
|
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
|
|
435
470
|
session.lastActivity = Date.now();
|
|
471
|
+
if (session.pendingFinalDeliveryText) {
|
|
472
|
+
session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
|
|
473
|
+
writeTurn();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
436
476
|
if (session.state.state === 'running' && intent !== 'interrupt') {
|
|
437
477
|
session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
|
|
438
478
|
writeTurn();
|
|
@@ -471,7 +511,6 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
471
511
|
.filter((b) => b.type === 'text')
|
|
472
512
|
.map((b) => b.text);
|
|
473
513
|
const fullText = textBlocks.join('\n').trim();
|
|
474
|
-
clearStreaming();
|
|
475
514
|
if (fullText) {
|
|
476
515
|
session.pendingFinalText = fullText;
|
|
477
516
|
}
|
|
@@ -510,18 +549,14 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
510
549
|
session.currentTurnId = randomUUID();
|
|
511
550
|
session.currentTurnOpenedAt = Date.now();
|
|
512
551
|
}
|
|
552
|
+
session.state.contextUsage = undefined;
|
|
513
553
|
session.pendingFinalText = null;
|
|
514
554
|
session.toolInProgress = false;
|
|
515
555
|
session.turnState = 'thinking';
|
|
516
556
|
client.setTyping(conversationId, true, 'thinking').catch(() => { });
|
|
517
557
|
}
|
|
518
558
|
else if (state === 'idle') {
|
|
519
|
-
clearIdleResetTimer();
|
|
520
|
-
session.turnState = 'completed';
|
|
521
559
|
client.setTyping(conversationId, false).catch(() => { });
|
|
522
|
-
clearStreaming();
|
|
523
|
-
writeTurn();
|
|
524
|
-
scheduleIdleTurnReset();
|
|
525
560
|
}
|
|
526
561
|
else if (state === 'requires_action') {
|
|
527
562
|
clearIdleResetTimer();
|
|
@@ -548,42 +583,25 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
548
583
|
? msg.result.trim()
|
|
549
584
|
: '';
|
|
550
585
|
const finalText = resultText || session.pendingFinalText?.trim() || null;
|
|
551
|
-
|
|
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
|
-
}
|
|
586
|
+
const finalDelivered = finalText ? await deliverFinalReply(finalText) : true;
|
|
567
587
|
try {
|
|
568
588
|
const usage = await q.getContextUsage();
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
},
|
|
577
|
-
cwd,
|
|
578
|
-
hostMode: true,
|
|
579
|
-
isActive: true,
|
|
580
|
-
}).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();
|
|
581
596
|
}
|
|
582
597
|
catch {
|
|
583
598
|
// Non-fatal
|
|
584
599
|
}
|
|
585
|
-
if (
|
|
586
|
-
|
|
600
|
+
if (finalDelivered) {
|
|
601
|
+
scheduleFinalHandoffReset();
|
|
602
|
+
}
|
|
603
|
+
else if (finalText) {
|
|
604
|
+
scheduleFinalDeliveryRetry(finalText);
|
|
587
605
|
}
|
|
588
606
|
break;
|
|
589
607
|
}
|
|
@@ -620,7 +638,7 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
620
638
|
type: 'user',
|
|
621
639
|
message: {
|
|
622
640
|
role: 'user',
|
|
623
|
-
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).',
|
|
624
642
|
},
|
|
625
643
|
parent_tool_use_id: null,
|
|
626
644
|
});
|
|
@@ -631,17 +649,19 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
631
649
|
const modelList = toModelOptions(models);
|
|
632
650
|
session.availableModels = modelList;
|
|
633
651
|
runtimeModels = modelList;
|
|
634
|
-
|
|
652
|
+
onRuntimeDescriptorUpdate({
|
|
635
653
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
636
654
|
defaultModel: session.state.model ?? modelList[0]?.value,
|
|
637
655
|
defaultPermissionMode: 'default',
|
|
638
656
|
availableModels: modelList,
|
|
639
|
-
availableWorkspaces:
|
|
640
|
-
})
|
|
657
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
658
|
+
});
|
|
641
659
|
writeSessionState(conversationId, agentId, {
|
|
642
660
|
...session.state,
|
|
643
661
|
availableModels: modelList,
|
|
644
662
|
cwd,
|
|
663
|
+
executionMode: session.environment.mode,
|
|
664
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
645
665
|
hostMode: true,
|
|
646
666
|
isActive: true,
|
|
647
667
|
}).catch(() => { });
|
|
@@ -650,7 +670,8 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
650
670
|
catch {
|
|
651
671
|
writeState();
|
|
652
672
|
}
|
|
653
|
-
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})`);
|
|
654
675
|
};
|
|
655
676
|
init().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err));
|
|
656
677
|
return session;
|
|
@@ -661,12 +682,17 @@ async function main() {
|
|
|
661
682
|
options: {
|
|
662
683
|
cwd: { type: 'string' },
|
|
663
684
|
workspace: { type: 'string', multiple: true },
|
|
685
|
+
'enable-worktrees': { type: 'boolean' },
|
|
664
686
|
},
|
|
665
687
|
strict: false,
|
|
666
688
|
});
|
|
667
689
|
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
668
690
|
const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
|
|
669
|
-
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
|
+
}
|
|
670
696
|
const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-host]' });
|
|
671
697
|
console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
672
698
|
// ── Canon API client + RTDB auth ──
|
|
@@ -690,19 +716,36 @@ async function main() {
|
|
|
690
716
|
}
|
|
691
717
|
console.error(`[canon-host] Authenticated as ${agentId}`);
|
|
692
718
|
}
|
|
719
|
+
let streamConnected = false;
|
|
720
|
+
let runtimeDescriptor = {
|
|
721
|
+
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
722
|
+
defaultPermissionMode: 'default',
|
|
723
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
724
|
+
};
|
|
725
|
+
const publishRuntimeHeartbeat = async () => {
|
|
726
|
+
if (!streamConnected)
|
|
727
|
+
return;
|
|
728
|
+
await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
|
|
729
|
+
console.error('[canon-host] Failed to publish agent runtime:', error);
|
|
730
|
+
});
|
|
731
|
+
};
|
|
693
732
|
// ── Load conversations (informational — sessions created on demand) ──
|
|
694
733
|
try {
|
|
695
734
|
runtimeModels = await detectRuntimeModels(workingDir);
|
|
696
|
-
|
|
735
|
+
runtimeDescriptor = {
|
|
697
736
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
698
737
|
defaultModel: runtimeModels[0]?.value,
|
|
699
738
|
defaultPermissionMode: 'default',
|
|
700
739
|
availableModels: runtimeModels,
|
|
701
|
-
availableWorkspaces:
|
|
702
|
-
}
|
|
740
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
741
|
+
};
|
|
703
742
|
}
|
|
704
|
-
catch
|
|
705
|
-
|
|
743
|
+
catch {
|
|
744
|
+
runtimeDescriptor = {
|
|
745
|
+
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
746
|
+
defaultPermissionMode: 'default',
|
|
747
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
748
|
+
};
|
|
706
749
|
}
|
|
707
750
|
try {
|
|
708
751
|
const convos = await client.getConversations();
|
|
@@ -714,7 +757,7 @@ async function main() {
|
|
|
714
757
|
// ── Session manager ──
|
|
715
758
|
const sessions = new Map();
|
|
716
759
|
const pendingSessionCreations = new Map();
|
|
717
|
-
/** Saved SDK session IDs for resume after idle eviction */
|
|
760
|
+
/** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
|
|
718
761
|
const savedSessionIds = new Map();
|
|
719
762
|
const conversationCache = new Map();
|
|
720
763
|
let conversationCacheLoadedAt = 0;
|
|
@@ -745,17 +788,9 @@ async function main() {
|
|
|
745
788
|
async function loadParticipantContext(input) {
|
|
746
789
|
const [conversation, recentMessages] = await Promise.all([
|
|
747
790
|
getConversationMeta(input.conversationId),
|
|
748
|
-
client.getMessages(input.conversationId,
|
|
791
|
+
client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
|
|
749
792
|
]);
|
|
750
|
-
const
|
|
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
|
-
}
|
|
793
|
+
const history = buildParticipationHistorySnapshot(recentMessages, agentId);
|
|
759
794
|
return {
|
|
760
795
|
conversationType: conversation?.type ?? 'unknown',
|
|
761
796
|
memberCount: conversation?.memberIds?.length ?? null,
|
|
@@ -763,10 +798,11 @@ async function main() {
|
|
|
763
798
|
senderName: input.senderName,
|
|
764
799
|
isOwner: input.isOwner,
|
|
765
800
|
mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
|
|
766
|
-
recentSenderTypes,
|
|
767
|
-
recentHumanCount:
|
|
768
|
-
recentAgentCount:
|
|
769
|
-
consecutiveAgentTurns,
|
|
801
|
+
recentSenderTypes: history.recentSenderTypes,
|
|
802
|
+
recentHumanCount: history.recentHumanCount,
|
|
803
|
+
recentAgentCount: history.recentAgentCount,
|
|
804
|
+
consecutiveAgentTurns: history.consecutiveAgentTurns,
|
|
805
|
+
currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
|
|
770
806
|
};
|
|
771
807
|
}
|
|
772
808
|
function closeSession(conversationId) {
|
|
@@ -774,9 +810,10 @@ async function main() {
|
|
|
774
810
|
if (!session)
|
|
775
811
|
return;
|
|
776
812
|
session.closed = true;
|
|
813
|
+
releaseConversationEnvironment(session.environment);
|
|
777
814
|
// Save SDK session ID for future resume
|
|
778
815
|
if (session.sdkSessionId) {
|
|
779
|
-
savedSessionIds.set(conversationId, session.sdkSessionId);
|
|
816
|
+
savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
|
|
780
817
|
}
|
|
781
818
|
if (session.streamingTimer)
|
|
782
819
|
clearTimeout(session.streamingTimer);
|
|
@@ -784,6 +821,10 @@ async function main() {
|
|
|
784
821
|
clearTimeout(session.idleResetTimer);
|
|
785
822
|
session.idleResetTimer = null;
|
|
786
823
|
}
|
|
824
|
+
if (session.finalDeliveryTimer) {
|
|
825
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
826
|
+
session.finalDeliveryTimer = null;
|
|
827
|
+
}
|
|
787
828
|
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
788
829
|
clearSessionState(conversationId, agentId).catch(() => { });
|
|
789
830
|
clearTurnState(conversationId, agentId).catch(() => { });
|
|
@@ -823,21 +864,43 @@ async function main() {
|
|
|
823
864
|
}
|
|
824
865
|
const creation = (async () => {
|
|
825
866
|
const config = await loadSessionConfig(conversationId, agentId);
|
|
826
|
-
const
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
867
|
+
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
868
|
+
const environment = prepareConversationEnvironment({
|
|
869
|
+
agentId,
|
|
870
|
+
conversationId,
|
|
871
|
+
workspaceCwd,
|
|
872
|
+
allowWorktrees,
|
|
873
|
+
});
|
|
874
|
+
try {
|
|
875
|
+
const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
|
|
876
|
+
const resumeId = savedSessionIds.get(resumeKey);
|
|
877
|
+
const session = createSession(conversationId, environment, agentId, client, (convoId, sdkSessionId) => {
|
|
878
|
+
// Called when session processing loop ends — save session ID for resume
|
|
879
|
+
const existing = sessions.get(convoId);
|
|
880
|
+
if (existing) {
|
|
881
|
+
releaseConversationEnvironment(existing.environment);
|
|
882
|
+
if (sdkSessionId) {
|
|
883
|
+
savedSessionIds.set(buildConversationEnvironmentKey(convoId, existing.environment.baseCwd), sdkSessionId);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
sessions.delete(convoId);
|
|
887
|
+
clearSessionState(convoId, agentId).catch(() => { });
|
|
888
|
+
clearTurnState(convoId, agentId).catch(() => { });
|
|
889
|
+
}, (descriptor) => {
|
|
890
|
+
runtimeDescriptor = descriptor;
|
|
891
|
+
void publishRuntimeHeartbeat();
|
|
892
|
+
}, {
|
|
893
|
+
model: config?.model ?? runtimeModels[0]?.value,
|
|
894
|
+
permissionMode: config?.permissionMode ?? 'default',
|
|
895
|
+
availableModels: runtimeModels,
|
|
896
|
+
}, resumeId);
|
|
897
|
+
sessions.set(conversationId, session);
|
|
898
|
+
return session;
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
releaseConversationEnvironment(environment);
|
|
902
|
+
throw error;
|
|
903
|
+
}
|
|
841
904
|
})();
|
|
842
905
|
pendingSessionCreations.set(conversationId, creation);
|
|
843
906
|
try {
|
|
@@ -873,19 +936,31 @@ async function main() {
|
|
|
873
936
|
const content = renderInboundContent(m);
|
|
874
937
|
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
875
938
|
void (async () => {
|
|
939
|
+
const conversation = await getConversationMeta(convoId);
|
|
940
|
+
const behavior = payload.behavior ?? conversation?.behavior;
|
|
876
941
|
const participantContext = await loadParticipantContext({
|
|
877
942
|
conversationId: convoId,
|
|
878
943
|
message: m,
|
|
879
944
|
senderName: sender,
|
|
880
945
|
isOwner,
|
|
881
946
|
});
|
|
882
|
-
const autoReply = decideAutoReply(participantContext);
|
|
947
|
+
const autoReply = decideAutoReply(participantContext, behavior);
|
|
883
948
|
if (!autoReply.allow) {
|
|
884
949
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
885
950
|
return;
|
|
886
951
|
}
|
|
887
952
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
888
|
-
|
|
953
|
+
let session;
|
|
954
|
+
try {
|
|
955
|
+
session = await getOrCreateSession(convoId);
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
959
|
+
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
960
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
961
|
+
await client.sendMessage(convoId, `I couldn't start a coding session for this workspace: ${userMessage}`).catch(() => { });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
889
964
|
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
890
965
|
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
891
966
|
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
@@ -897,14 +972,23 @@ async function main() {
|
|
|
897
972
|
content,
|
|
898
973
|
conversationId: convoId,
|
|
899
974
|
participantContext,
|
|
975
|
+
behavior,
|
|
900
976
|
}),
|
|
901
977
|
},
|
|
902
978
|
parent_tool_use_id: null,
|
|
903
979
|
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
|
904
980
|
})();
|
|
905
981
|
},
|
|
906
|
-
onConnected: () =>
|
|
907
|
-
|
|
982
|
+
onConnected: () => {
|
|
983
|
+
streamConnected = true;
|
|
984
|
+
void publishRuntimeHeartbeat();
|
|
985
|
+
console.error('[canon-host] SSE connected');
|
|
986
|
+
},
|
|
987
|
+
onDisconnected: () => {
|
|
988
|
+
streamConnected = false;
|
|
989
|
+
rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
|
|
990
|
+
console.error('[canon-host] SSE disconnected');
|
|
991
|
+
},
|
|
908
992
|
onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
|
|
909
993
|
},
|
|
910
994
|
});
|
|
@@ -960,6 +1044,8 @@ async function main() {
|
|
|
960
1044
|
...session.state,
|
|
961
1045
|
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
962
1046
|
cwd: session.cwd,
|
|
1047
|
+
executionMode: session.environment.mode,
|
|
1048
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
963
1049
|
hostMode: true,
|
|
964
1050
|
isActive: true,
|
|
965
1051
|
}).catch(() => { });
|
|
@@ -1019,6 +1105,8 @@ async function main() {
|
|
|
1019
1105
|
...session.state,
|
|
1020
1106
|
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
1021
1107
|
cwd: session.cwd,
|
|
1108
|
+
executionMode: session.environment.mode,
|
|
1109
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
1022
1110
|
hostMode: true,
|
|
1023
1111
|
isActive: true,
|
|
1024
1112
|
}).catch(() => { });
|
|
@@ -1035,6 +1123,7 @@ async function main() {
|
|
|
1035
1123
|
: {}),
|
|
1036
1124
|
}).catch(() => { });
|
|
1037
1125
|
}
|
|
1126
|
+
void publishRuntimeHeartbeat();
|
|
1038
1127
|
}, HEARTBEAT_MS);
|
|
1039
1128
|
// ── Idle session cleanup ──
|
|
1040
1129
|
const idleCheck = setInterval(() => {
|
|
@@ -1056,6 +1145,7 @@ async function main() {
|
|
|
1056
1145
|
clearInterval(heartbeat);
|
|
1057
1146
|
clearInterval(idleCheck);
|
|
1058
1147
|
stream.stop();
|
|
1148
|
+
await rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
|
|
1059
1149
|
for (const convoId of [...sessions.keys()]) {
|
|
1060
1150
|
closeSession(convoId);
|
|
1061
1151
|
}
|
package/dist/inbound-policy.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
|
|
2
2
|
export interface InboundParticipantContext {
|
|
3
3
|
conversationType: CanonConversation['type'] | 'unknown';
|
|
4
4
|
memberCount: number | null;
|
|
@@ -10,10 +10,11 @@ export interface InboundParticipantContext {
|
|
|
10
10
|
recentHumanCount: number;
|
|
11
11
|
recentAgentCount: number;
|
|
12
12
|
consecutiveAgentTurns: number;
|
|
13
|
+
currentAgentStreakStartedByHuman: boolean;
|
|
13
14
|
}
|
|
14
15
|
export interface AutoReplyDecision {
|
|
15
16
|
allow: boolean;
|
|
16
17
|
reason: string;
|
|
17
18
|
}
|
|
18
19
|
export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
|
|
19
|
-
export declare function decideAutoReply(context: InboundParticipantContext): AutoReplyDecision;
|
|
20
|
+
export declare function decideAutoReply(context: InboundParticipantContext, behavior?: ResolvedAgentBehaviorPolicy | null): AutoReplyDecision;
|
package/dist/inbound-policy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
|
|
1
2
|
function formatRecentSenders(senderTypes) {
|
|
2
3
|
if (senderTypes.length === 0)
|
|
3
4
|
return 'none';
|
|
@@ -22,29 +23,21 @@ export function buildInboundContextLines(context) {
|
|
|
22
23
|
`Recent human messages: ${context.recentHumanCount}`,
|
|
23
24
|
`Recent agent messages: ${context.recentAgentCount}`,
|
|
24
25
|
`Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
|
|
26
|
+
`Current agent streak started after a human message: ${context.currentAgentStreakStartedByHuman ? 'yes' : 'no'}`,
|
|
25
27
|
];
|
|
26
28
|
}
|
|
27
|
-
export function decideAutoReply(context) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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' };
|
|
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
|
+
};
|
|
50
43
|
}
|
package/dist/register.js
CHANGED
|
@@ -33,6 +33,14 @@ if (!values.name || !values.description || !values.phone) {
|
|
|
33
33
|
}
|
|
34
34
|
// Default profile name: sanitized agent name
|
|
35
35
|
const profileName = values.profile || values.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
|
36
|
+
let existingAgentId;
|
|
37
|
+
try {
|
|
38
|
+
const profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
|
|
39
|
+
existingAgentId = profiles[profileName]?.agentId;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// File doesn't exist yet
|
|
43
|
+
}
|
|
36
44
|
console.log(`Registering agent "${values.name}" (profile: ${profileName})...`);
|
|
37
45
|
const result = await registerAndWaitForApproval({
|
|
38
46
|
name: values.name,
|
|
@@ -41,6 +49,7 @@ const result = await registerAndWaitForApproval({
|
|
|
41
49
|
developerInfo: 'Claude Code plugin',
|
|
42
50
|
clientType: 'claude-code',
|
|
43
51
|
baseUrl: values['base-url'],
|
|
52
|
+
requestedAgentId: existingAgentId,
|
|
44
53
|
}, {
|
|
45
54
|
onSubmitted: (requestId) => {
|
|
46
55
|
console.log(`Registration submitted (request ID: ${requestId}).`);
|
package/dist/server.js
CHANGED
|
@@ -325,6 +325,18 @@ async function handleInboundMessage(payload) {
|
|
|
325
325
|
content_type: m.contentType || 'text',
|
|
326
326
|
ts: new Date().toISOString(),
|
|
327
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
|
+
}
|
|
328
340
|
const primaryAttachment = getPrimaryAttachment(m);
|
|
329
341
|
if (m.imageUrl)
|
|
330
342
|
meta.image_url = m.imageUrl;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@anthropic-ai/claude-agent-sdk": "^0.2.101",
|
|
25
|
-
"@canonmsg/core": "^0.
|
|
25
|
+
"@canonmsg/core": "^0.5.0",
|
|
26
26
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|