@canonmsg/claude-code-plugin 0.4.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 +182 -122
- package/dist/inbound-policy.d.ts +3 -2
- package/dist/inbound-policy.js +16 -23
- 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, 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
|
});
|
|
@@ -636,12 +654,14 @@ function createSession(conversationId, cwd, agentId, client, onSessionEnd, confi
|
|
|
636
654
|
defaultModel: session.state.model ?? modelList[0]?.value,
|
|
637
655
|
defaultPermissionMode: 'default',
|
|
638
656
|
availableModels: modelList,
|
|
639
|
-
availableWorkspaces:
|
|
657
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
640
658
|
}).catch(() => { });
|
|
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 ──
|
|
@@ -698,7 +724,7 @@ async function main() {
|
|
|
698
724
|
defaultModel: runtimeModels[0]?.value,
|
|
699
725
|
defaultPermissionMode: 'default',
|
|
700
726
|
availableModels: runtimeModels,
|
|
701
|
-
availableWorkspaces:
|
|
727
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
702
728
|
});
|
|
703
729
|
}
|
|
704
730
|
catch (error) {
|
|
@@ -714,7 +740,7 @@ async function main() {
|
|
|
714
740
|
// ── Session manager ──
|
|
715
741
|
const sessions = new Map();
|
|
716
742
|
const pendingSessionCreations = new Map();
|
|
717
|
-
/** Saved SDK session IDs for resume after idle eviction */
|
|
743
|
+
/** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
|
|
718
744
|
const savedSessionIds = new Map();
|
|
719
745
|
const conversationCache = new Map();
|
|
720
746
|
let conversationCacheLoadedAt = 0;
|
|
@@ -745,17 +771,9 @@ async function main() {
|
|
|
745
771
|
async function loadParticipantContext(input) {
|
|
746
772
|
const [conversation, recentMessages] = await Promise.all([
|
|
747
773
|
getConversationMeta(input.conversationId),
|
|
748
|
-
client.getMessages(input.conversationId,
|
|
774
|
+
client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
|
|
749
775
|
]);
|
|
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
|
-
}
|
|
776
|
+
const history = buildParticipationHistorySnapshot(recentMessages, agentId);
|
|
759
777
|
return {
|
|
760
778
|
conversationType: conversation?.type ?? 'unknown',
|
|
761
779
|
memberCount: conversation?.memberIds?.length ?? null,
|
|
@@ -763,10 +781,11 @@ async function main() {
|
|
|
763
781
|
senderName: input.senderName,
|
|
764
782
|
isOwner: input.isOwner,
|
|
765
783
|
mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
|
|
766
|
-
recentSenderTypes,
|
|
767
|
-
recentHumanCount:
|
|
768
|
-
recentAgentCount:
|
|
769
|
-
consecutiveAgentTurns,
|
|
784
|
+
recentSenderTypes: history.recentSenderTypes,
|
|
785
|
+
recentHumanCount: history.recentHumanCount,
|
|
786
|
+
recentAgentCount: history.recentAgentCount,
|
|
787
|
+
consecutiveAgentTurns: history.consecutiveAgentTurns,
|
|
788
|
+
currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
|
|
770
789
|
};
|
|
771
790
|
}
|
|
772
791
|
function closeSession(conversationId) {
|
|
@@ -774,9 +793,10 @@ async function main() {
|
|
|
774
793
|
if (!session)
|
|
775
794
|
return;
|
|
776
795
|
session.closed = true;
|
|
796
|
+
releaseConversationEnvironment(session.environment);
|
|
777
797
|
// Save SDK session ID for future resume
|
|
778
798
|
if (session.sdkSessionId) {
|
|
779
|
-
savedSessionIds.set(conversationId, session.sdkSessionId);
|
|
799
|
+
savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
|
|
780
800
|
}
|
|
781
801
|
if (session.streamingTimer)
|
|
782
802
|
clearTimeout(session.streamingTimer);
|
|
@@ -784,6 +804,10 @@ async function main() {
|
|
|
784
804
|
clearTimeout(session.idleResetTimer);
|
|
785
805
|
session.idleResetTimer = null;
|
|
786
806
|
}
|
|
807
|
+
if (session.finalDeliveryTimer) {
|
|
808
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
809
|
+
session.finalDeliveryTimer = null;
|
|
810
|
+
}
|
|
787
811
|
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
788
812
|
clearSessionState(conversationId, agentId).catch(() => { });
|
|
789
813
|
clearTurnState(conversationId, agentId).catch(() => { });
|
|
@@ -823,21 +847,40 @@ async function main() {
|
|
|
823
847
|
}
|
|
824
848
|
const creation = (async () => {
|
|
825
849
|
const config = await loadSessionConfig(conversationId, agentId);
|
|
826
|
-
const
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
+
}
|
|
841
884
|
})();
|
|
842
885
|
pendingSessionCreations.set(conversationId, creation);
|
|
843
886
|
try {
|
|
@@ -873,19 +916,31 @@ async function main() {
|
|
|
873
916
|
const content = renderInboundContent(m);
|
|
874
917
|
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
875
918
|
void (async () => {
|
|
919
|
+
const conversation = await getConversationMeta(convoId);
|
|
920
|
+
const behavior = payload.behavior ?? conversation?.behavior;
|
|
876
921
|
const participantContext = await loadParticipantContext({
|
|
877
922
|
conversationId: convoId,
|
|
878
923
|
message: m,
|
|
879
924
|
senderName: sender,
|
|
880
925
|
isOwner,
|
|
881
926
|
});
|
|
882
|
-
const autoReply = decideAutoReply(participantContext);
|
|
927
|
+
const autoReply = decideAutoReply(participantContext, behavior);
|
|
883
928
|
if (!autoReply.allow) {
|
|
884
929
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
885
930
|
return;
|
|
886
931
|
}
|
|
887
932
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
888
|
-
|
|
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
|
+
}
|
|
889
944
|
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
890
945
|
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
891
946
|
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
@@ -897,6 +952,7 @@ async function main() {
|
|
|
897
952
|
content,
|
|
898
953
|
conversationId: convoId,
|
|
899
954
|
participantContext,
|
|
955
|
+
behavior,
|
|
900
956
|
}),
|
|
901
957
|
},
|
|
902
958
|
parent_tool_use_id: null,
|
|
@@ -960,6 +1016,8 @@ async function main() {
|
|
|
960
1016
|
...session.state,
|
|
961
1017
|
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
962
1018
|
cwd: session.cwd,
|
|
1019
|
+
executionMode: session.environment.mode,
|
|
1020
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
963
1021
|
hostMode: true,
|
|
964
1022
|
isActive: true,
|
|
965
1023
|
}).catch(() => { });
|
|
@@ -1019,6 +1077,8 @@ async function main() {
|
|
|
1019
1077
|
...session.state,
|
|
1020
1078
|
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
1021
1079
|
cwd: session.cwd,
|
|
1080
|
+
executionMode: session.environment.mode,
|
|
1081
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
1022
1082
|
hostMode: true,
|
|
1023
1083
|
isActive: true,
|
|
1024
1084
|
}).catch(() => { });
|
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/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.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",
|
|
@@ -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.4.0",
|
|
26
26
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|