@canonmsg/claude-code-plugin 0.29.1 → 0.29.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "Canon",
3
3
  "description": "Connect Claude Code to Canon — messaging where AI agents are first-class citizens",
4
- "version": "0.29.1",
4
+ "version": "0.29.2",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
@@ -29,5 +29,4 @@ export declare class ApprovalHttpServer {
29
29
  private hasValidToken;
30
30
  private handleApproval;
31
31
  private sendHookResponse;
32
- private classifyRisk;
33
32
  }
@@ -131,8 +131,9 @@ export class ApprovalHttpServer {
131
131
  }
132
132
  const toolName = payload.tool_name ?? 'Unknown';
133
133
  const toolInput = payload.tool_input ?? {};
134
- // Determine risk level
135
- const riskLevel = this.classifyRisk(toolName, toolInput);
134
+ // Single source of truth for risk, shared with host mode (tool-policy.ts).
135
+ const risk = classifyClaudeApprovalRisk(toolName, toolInput);
136
+ const riskLevel = risk === 'destructive' ? 'destructive' : 'normal';
136
137
  const isOwnerTurn = this.getIsOwnerTurn();
137
138
  // Find the conversation to send the approval to.
138
139
  const conversationId = this.getConversationId();
@@ -154,7 +155,7 @@ export class ApprovalHttpServer {
154
155
  try {
155
156
  const result = await this.manager.requestApproval(conversationId, toolName, toolInput, {
156
157
  riskLevel,
157
- risk: classifyClaudeApprovalRisk(toolName, toolInput),
158
+ risk,
158
159
  category: classifyClaudeApprovalCategory(toolName),
159
160
  native: {
160
161
  runtime: 'claude-code',
@@ -182,19 +183,4 @@ export class ApprovalHttpServer {
182
183
  },
183
184
  }));
184
185
  }
185
- classifyRisk(toolName, toolInput) {
186
- if (toolName !== 'Bash')
187
- return 'normal';
188
- const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
189
- const destructivePatterns = [
190
- /\brm\s+(-rf?|--force)/,
191
- /\bgit\s+push\s+--force/,
192
- /\bgit\s+reset\s+--hard/,
193
- /\bdrop\s+(table|database)/i,
194
- /\bformat\b/,
195
- ];
196
- return destructivePatterns.some((p) => p.test(cmd))
197
- ? 'destructive'
198
- : 'normal';
199
- }
200
186
  }
package/dist/host.js CHANGED
@@ -27,16 +27,14 @@ import { existsSync } from 'node:fs';
27
27
  import { readFile } from 'node:fs/promises';
28
28
  import { query, } from '@anthropic-ai/claude-agent-sdk';
29
29
  import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
30
- import { captureTurnArtifactSnapshot, collectTurnArtifacts, IDLE_TIMEOUT_MS, } from '@canonmsg/coding-agent-host';
31
- import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, buildBoundedTurnTrail, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, readLocalRuntimeEntry, resolveCanonAgent, verifyResolvedAgentEnvironment, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
32
- import { decideAutoReply, } from './inbound-policy.js';
33
- import { runCli } from './cli-entry.js';
30
+ import { captureTurnArtifactSnapshot, collectTurnArtifacts, IDLE_TIMEOUT_MS, collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
31
+ import { EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, buildBoundedTurnTrail, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
32
+ import { runCli } from '@canonmsg/core';
34
33
  import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
35
34
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
36
35
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
37
36
  import { applyClaudeSessionControl, boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, prepareTurnTrailForDelivery, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, isOpenClaudeTurnState, takeClaudeResultOwner, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeFinalText, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
38
37
  import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
39
- import { collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
40
38
  function parseRuntimeVisibilityPreset(value) {
41
39
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
42
40
  }
@@ -194,7 +192,7 @@ function buildClaudeRuntimeDescriptor(input) {
194
192
  label: 'Level',
195
193
  kind: 'enum',
196
194
  required: true,
197
- choices: [...CLAUDE_EFFORT_OPTIONS],
195
+ choices: [...EFFORT_OPTIONS],
198
196
  }],
199
197
  dispatch: {
200
198
  kind: 'control',
@@ -237,7 +235,7 @@ function buildClaudeRuntimeDescriptor(input) {
237
235
  executionModes: input.executionModes,
238
236
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
239
237
  defaultPermissionMode: 'default',
240
- effortOptions: [...CLAUDE_EFFORT_OPTIONS],
238
+ effortOptions: [...EFFORT_OPTIONS],
241
239
  extraRuntimeControls: [{
242
240
  id: 'ultracode',
243
241
  label: 'Ultracode',
@@ -272,7 +270,7 @@ function buildClaudeRuntimeDescriptor(input) {
272
270
  commands,
273
271
  });
274
272
  }
275
- const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_OPTIONS.map((option) => option.value));
273
+ const CLAUDE_EFFORT_VALUES = new Set(EFFORT_OPTIONS.map((option) => option.value));
276
274
  function parseClaudePermissionMode(value) {
277
275
  if (value === 'acceptEdits'
278
276
  || value === 'auto'
@@ -376,13 +374,14 @@ function acceptClaudeCli(source, cliPath) {
376
374
  function toModelOptions(models) {
377
375
  return models.map(claudeModelInfoToOption);
378
376
  }
379
- async function publishAgentRuntime(agentId, runtime) {
380
- await publishHostAgentRuntime(agentId, 'claude-code', runtime);
377
+ async function publishAgentRuntime(agentId, runtime, rtdb) {
378
+ await publishHostAgentRuntime(agentId, 'claude-code', runtime, rtdb);
381
379
  }
382
- async function loadSessionConfig(conversationId, agentId) {
380
+ async function loadSessionConfig(conversationId, agentId, rtdb) {
383
381
  const config = await loadHostSessionConfig({
384
382
  conversationId,
385
383
  agentId,
384
+ rtdb,
386
385
  extraStringFields: ['permissionMode', 'effort'],
387
386
  });
388
387
  return {
@@ -2415,6 +2414,7 @@ export async function main() {
2415
2414
  agentId,
2416
2415
  clientType: 'claude-code',
2417
2416
  hostMode: true,
2417
+ rtdb,
2418
2418
  });
2419
2419
  let streamConnected = false;
2420
2420
  const hostAvailableExecutionModes = [
@@ -2455,6 +2455,7 @@ export async function main() {
2455
2455
  await publishHostSessionSnapshots({
2456
2456
  conversationIds,
2457
2457
  agentId,
2458
+ rtdb,
2458
2459
  clientType: 'claude-code',
2459
2460
  runtime: runtimeDescriptor,
2460
2461
  workspaceOptions,
@@ -2499,7 +2500,7 @@ export async function main() {
2499
2500
  console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
2500
2501
  return runtimeMetadata;
2501
2502
  });
2502
- await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
2503
+ await publishAgentRuntime(agentId, runtimeDescriptor, rtdb).catch((error) => {
2503
2504
  console.error('[canon-host] Failed to publish agent runtime:', error);
2504
2505
  });
2505
2506
  await publishSessionSnapshots(Array.from(knownConversationIds));
@@ -2860,7 +2861,7 @@ export async function main() {
2860
2861
  evictOldestIdle();
2861
2862
  }
2862
2863
  const creation = (async () => {
2863
- const config = await loadSessionConfig(conversationId, agentId);
2864
+ const config = await loadSessionConfig(conversationId, agentId, rtdb);
2864
2865
  const sessionExecutionMode = resolveSessionExecutionMode(config);
2865
2866
  const workspaceCwd = resolveWorkspaceCwd(config);
2866
2867
  const environment = prepareConversationEnvironment({
@@ -3114,40 +3115,21 @@ export async function main() {
3114
3115
  }
3115
3116
  return consumed;
3116
3117
  }
3117
- function persistedConversationCursors(conversationId) {
3118
- const canonicalCursor = loadRuntimeSessionState(runtimeId, {
3118
+ function persistedConversationCursor(conversationId) {
3119
+ return loadRuntimeSessionState(runtimeId, {
3119
3120
  conversationId,
3120
3121
  baseCwd: workingDir,
3121
3122
  workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3122
3123
  })?.lastInboundMessageId ?? null;
3123
- if (canonicalCursor)
3124
- return { canonicalCursor, legacyCursors: [] };
3125
- const legacyCursors = Object.values(readLocalRuntimeEntry(runtimeId)?.sessions ?? {})
3126
- .filter((state) => state.conversationId === conversationId)
3127
- .map((state) => state.lastInboundMessageId)
3128
- .filter((messageId) => Boolean(messageId));
3129
- return { canonicalCursor: null, legacyCursors: [...new Set(legacyCursors)] };
3130
3124
  }
3131
3125
  async function performMissedInboundRecovery() {
3132
3126
  for (const conversationId of knownConversationIds) {
3133
3127
  try {
3134
- const cursors = persistedConversationCursors(conversationId);
3135
3128
  const recovered = await collectMissedInboundMessages({
3136
3129
  fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
3137
- cursor: cursors.canonicalCursor,
3138
- cursorCandidates: cursors.legacyCursors,
3130
+ cursor: persistedConversationCursor(conversationId),
3139
3131
  agentId,
3140
3132
  });
3141
- if (!cursors.canonicalCursor
3142
- && recovered.cursor
3143
- && recovered.messages.length === 0) {
3144
- saveRuntimeSessionState(runtimeId, {
3145
- conversationId,
3146
- baseCwd: workingDir,
3147
- workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3148
- lastInboundMessageId: recovered.cursor,
3149
- });
3150
- }
3151
3133
  if (recovered.mode === 'truncated-window') {
3152
3134
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
3153
3135
  }
@@ -1,2 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * CLI script for Canon agent registration.
4
+ * Invoked by the /canon-register skill.
5
+ *
6
+ * Usage:
7
+ * node register.js --name "My Agent" --description "What it does" --phone "+15551234567" [--profile "my-agent"]
8
+ *
9
+ * On approval, saves the agent to ~/.canon/agents.json under the profile name.
10
+ */
2
11
  export declare function main(): Promise<void>;
package/dist/register.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { setDefaultResultOrder } from 'node:dns';
3
2
  /**
4
3
  * CLI script for Canon agent registration.
5
4
  * Invoked by the /canon-register skill.
@@ -9,9 +8,7 @@ import { setDefaultResultOrder } from 'node:dns';
9
8
  *
10
9
  * On approval, saves the agent to ~/.canon/agents.json under the profile name.
11
10
  */
12
- import { ackRegistrationApproval, clearPendingRegistration, getOrCreatePendingRegistration, loadProfiles, registerAndWaitForApproval, resolveCanonRuntimeConnection, updatePendingRegistration, upsertAgentProfile, verifyCanonRuntimeConnection, } from '@canonmsg/core';
13
- import { parseArgs } from 'node:util';
14
- import { runCli } from './cli-entry.js';
11
+ import { registerRegistrationCli, runRegistrationCli, } from '@canonmsg/core';
15
12
  const HELP = `canon-register — register or reconnect a Claude Code agent in Canon
16
13
 
17
14
  USAGE
@@ -37,114 +34,17 @@ EXAMPLES
37
34
  canon-register --name "Frontend" --description "React work" --phone "+15551234567" --profile frontend
38
35
 
39
36
  After approval, start it with CANON_AGENT=<profile> canon-claude --cwd /path/to/project.`;
37
+ const OPTIONS = {
38
+ moduleUrl: import.meta.url,
39
+ clientType: 'claude-code',
40
+ cliName: 'canon-register',
41
+ hostBinName: 'canon-claude',
42
+ developerInfo: 'Claude Code plugin',
43
+ registeringLabel: 'agent',
44
+ usage: 'Usage: node register.js --name "Agent Name" --description "Description" --phone "+15551234567" [--profile "my-agent"]',
45
+ help: HELP,
46
+ };
40
47
  export async function main() {
41
- setDefaultResultOrder('ipv4first');
42
- const { values } = parseArgs({
43
- options: {
44
- name: { type: 'string' },
45
- description: { type: 'string' },
46
- phone: { type: 'string' },
47
- profile: { type: 'string' },
48
- environment: { type: 'string' },
49
- 'base-url': { type: 'string' },
50
- 'stream-url': { type: 'string' },
51
- 'rtdb-url': { type: 'string' },
52
- 'firebase-api-key': { type: 'string' },
53
- },
54
- strict: true,
55
- });
56
- if (!values.name || !values.description || !values.phone) {
57
- console.error('Usage: node register.js --name "Agent Name" --description "Description" --phone "+15551234567" [--profile "my-agent"]');
58
- process.exit(1);
59
- }
60
- // Default profile name: sanitized agent name
61
- const profileName = values.profile || values.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
62
- const environmentId = values.environment || process.env.CANON_ENVIRONMENT_ID;
63
- if (!environmentId) {
64
- throw new Error('--environment or CANON_ENVIRONMENT_ID is required');
65
- }
66
- const connection = resolveCanonRuntimeConnection({
67
- environmentId,
68
- apiBaseUrl: values['base-url'],
69
- streamUrl: values['stream-url'],
70
- rtdbUrl: values['rtdb-url'],
71
- firebaseWebApiKey: values['firebase-api-key'],
72
- });
73
- await verifyCanonRuntimeConnection(connection);
74
- const existingProfile = loadProfiles()[profileName];
75
- const existingAgentId = existingProfile?.environmentId === connection.environmentId
76
- ? existingProfile.agentId
77
- : undefined;
78
- console.log(`Registering agent "${values.name}" (profile: ${profileName})...`);
79
- const pending = getOrCreatePendingRegistration(profileName, 'claude-code', connection);
80
- const result = await registerAndWaitForApproval({
81
- name: values.name,
82
- description: values.description,
83
- ownerPhone: values.phone,
84
- developerInfo: 'Claude Code plugin',
85
- clientType: 'claude-code',
86
- baseUrl: connection.apiBaseUrl,
87
- requestedAgentId: existingAgentId,
88
- localRegistrationId: pending.localRegistrationId,
89
- }, {
90
- onSubmitted: (requestId, pollToken) => {
91
- updatePendingRegistration(profileName, {
92
- requestId,
93
- pollToken,
94
- clientType: 'claude-code',
95
- });
96
- console.log(`Registration submitted (request ID: ${requestId}).`);
97
- console.log('Waiting for approval in Canon app...');
98
- },
99
- onPollUpdate: () => {
100
- process.stdout.write('.');
101
- },
102
- });
103
- console.log(''); // newline after dots
104
- switch (result.status) {
105
- case 'approved': {
106
- if (!result.apiKey || !result.agentId || !result.agentName) {
107
- console.error('Approval completed but Canon did not return a usable API key. Run this command again to resume key pickup.');
108
- process.exit(1);
109
- }
110
- upsertAgentProfile(profileName, {
111
- apiKey: result.apiKey,
112
- agentId: result.agentId,
113
- agentName: result.agentName,
114
- registeredAt: new Date().toISOString(),
115
- environmentId: connection.environmentId,
116
- baseUrl: connection.apiBaseUrl,
117
- streamUrl: connection.streamUrl,
118
- rtdbUrl: connection.rtdbUrl,
119
- firebaseApiKey: connection.firebaseWebApiKey,
120
- clientType: 'claude-code',
121
- });
122
- if (result.requestId) {
123
- await ackRegistrationApproval(connection.apiBaseUrl, result.requestId, result.pollToken);
124
- }
125
- clearPendingRegistration(profileName);
126
- console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
127
- console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
128
- console.log('Start it with: CANON_AGENT=' + profileName + ' canon-claude --cwd /path/to/project');
129
- console.log('Keep that terminal open while you want Canon to reach the agent.');
130
- console.log('Closing it, logging out, rebooting, or sleeping long enough to stop the process takes this local agent offline until you revive it.');
131
- console.log('Docs: https://canonmail.com/agents/integrations');
132
- break;
133
- }
134
- case 'rejected':
135
- console.log('Registration was rejected.');
136
- process.exit(1);
137
- break;
138
- case 'timeout':
139
- console.log('Registration timed out (5 minutes). Try again later.');
140
- process.exit(1);
141
- break;
142
- }
48
+ await runRegistrationCli(OPTIONS);
143
49
  }
144
- runCli(import.meta.url, main, (error) => {
145
- console.error('[canon-register] Fatal error:', error);
146
- process.exit(1);
147
- }, {
148
- name: 'canon-register',
149
- help: HELP,
150
- });
50
+ registerRegistrationCli(OPTIONS);
package/dist/server.js CHANGED
@@ -10,10 +10,10 @@ import { setDefaultResultOrder } from 'node:dns';
10
10
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
11
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
12
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
13
- import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActiveProfileLock, markLocalRuntimeStopped, loadProfiles, isProfileLocked, resolveCanonAgent, verifyResolvedAgentEnvironment, getActiveProfile, releaseLock, upsertLocalRuntimeEntry, initRTDBAuth, writeSessionState, clearSessionState, renderCanonHostInboundContent, } from '@canonmsg/core';
13
+ import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActiveProfileLock, markLocalRuntimeStopped, loadProfiles, isProfileLocked, resolveCanonAgent, verifyResolvedAgentEnvironment, getActiveProfile, releaseLock, upsertLocalRuntimeEntry, initRTDBAuth, renderCanonHostInboundContent, } from '@canonmsg/core';
14
14
  import { materializeMessageMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
15
15
  import { ApprovalHttpServer } from './approval-server.js';
16
- import { runCli } from './cli-entry.js';
16
+ import { runCli } from '@canonmsg/core';
17
17
  import { parseReplyArgs, parseSendMessageArgs, parseSetTypingArgs, } from './mcp-args.js';
18
18
  import { canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, stampSendToTurnComplete, } from '@canonmsg/agent-tools';
19
19
  const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
@@ -39,6 +39,7 @@ let stream = null;
39
39
  let agentContext = null;
40
40
  let approvalManager = null;
41
41
  let approvalServer = null;
42
+ let rtdb = null;
42
43
  const conversationCache = new Map();
43
44
  const TURN_COMPLETE_METADATA = {
44
45
  turnSemantics: 'turn_complete',
@@ -486,7 +487,7 @@ async function startChannel() {
486
487
  }
487
488
  }
488
489
  // Initialize RTDB auth for session state reporting
489
- initRTDBAuth(client, {
490
+ rtdb = initRTDBAuth(client, {
490
491
  rtdbUrl: resolvedRuntime.rtdbUrl,
491
492
  firebaseApiKey: resolvedRuntime.firebaseApiKey,
492
493
  });
@@ -524,7 +525,7 @@ async function startChannel() {
524
525
  // Write initial session state
525
526
  const initialState = { cwd: process.cwd(), isActive: true };
526
527
  for (const convoId of conversationCache.keys()) {
527
- writeSessionState(convoId, agentId, initialState).catch((err) => console.error(`[canon] Failed to write initial state for ${convoId}:`, err));
528
+ rtdb?.writeSessionState(convoId, agentId, initialState).catch((err) => console.error(`[canon] Failed to write initial state for ${convoId}:`, err));
528
529
  }
529
530
  // Start SSE stream
530
531
  stream = new CanonStream({
@@ -580,7 +581,7 @@ export async function main() {
580
581
  // Mark all conversations as inactive
581
582
  if (agentContext) {
582
583
  for (const convoId of conversationCache.keys()) {
583
- clearSessionState(convoId, agentContext.agentId).catch(() => { });
584
+ rtdb?.clearSessionState(convoId, agentContext.agentId).catch(() => { });
584
585
  }
585
586
  }
586
587
  approvalManager?.dispose();
package/dist/setup.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
10
10
  import { join, dirname } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { runCli } from './cli-entry.js';
12
+ import { runCli } from '@canonmsg/core';
13
13
  const HELP = `canon-setup — install Claude Code Canon skills and print MCP setup
14
14
 
15
15
  USAGE
@@ -47,23 +47,21 @@ function installSkills() {
47
47
  }
48
48
  // ── Show MCP config instructions ───────────────────────────────────────
49
49
  function showMcpInstructions() {
50
- // Find the installed server.js path
51
- const serverPath = join(__dirname, 'server.js');
52
50
  console.log(`
53
51
  Add this to your project's .mcp.json (or ~/.mcp.json for global):
54
52
 
55
53
  {
56
54
  "mcpServers": {
57
55
  "canon-channel": {
58
- "command": "node",
59
- "args": ["${serverPath}"]
56
+ "command": "canon-channel-server"
60
57
  }
61
58
  }
62
59
  }
63
60
 
64
61
  Then start Claude Code with:
65
62
 
66
- claude --dangerously-load-development-channels server:canon-channel
63
+ CANON_API_KEY=agk_live_... CANON_ENVIRONMENT_ID=canon-prod-v1 \
64
+ claude --dangerously-load-development-channels server:canon-channel
67
65
  `);
68
66
  }
69
67
  // ── Main ───────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.29.1",
3
+ "version": "0.29.2",
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/index.js",
@@ -31,11 +31,11 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "0.3.220",
34
- "@canonmsg/agent-sdk": "^7.1.3",
35
- "@canonmsg/agent-tools": "^0.3.2",
36
- "@canonmsg/coding-agent-host": "^0.3.1",
37
- "@canonmsg/core": "^8.2.0",
38
- "@canonmsg/rich-cards": "^0.8.6",
34
+ "@canonmsg/agent-sdk": "^8.0.0",
35
+ "@canonmsg/agent-tools": "^0.3.3",
36
+ "@canonmsg/coding-agent-host": "^0.4.0",
37
+ "@canonmsg/core": "^9.0.0",
38
+ "@canonmsg/rich-cards": "^0.8.7",
39
39
  "@modelcontextprotocol/sdk": "^1.29.0"
40
40
  },
41
41
  "engines": {
@@ -1,2 +0,0 @@
1
- export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
2
- export type { CliMetadata, RunCliMetadataOptions, } from '@canonmsg/core';
package/dist/cli-entry.js DELETED
@@ -1 +0,0 @@
1
- export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
@@ -1,22 +0,0 @@
1
- import { type CanonGroupContext, type CanonGroupContextMode, 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
- groupContext?: CanonGroupContext;
10
- groupContextMode?: CanonGroupContextMode;
11
- recentSenderTypes: Array<'human' | 'ai_agent'>;
12
- recentHumanCount: number;
13
- recentAgentCount: number;
14
- consecutiveAgentTurns: number;
15
- currentAgentStreakStartedByHuman: boolean;
16
- }
17
- export interface AutoReplyDecision {
18
- allow: boolean;
19
- reason: string;
20
- }
21
- export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
22
- export declare function decideAutoReply(context: InboundParticipantContext, behavior?: ResolvedAgentBehaviorPolicy | null): AutoReplyDecision;
@@ -1,46 +0,0 @@
1
- import { buildCompactGroupContextLines, 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
- ...(context.groupContext && context.groupContextMode
22
- ? buildCompactGroupContextLines(context.groupContext, context.groupContextMode)
23
- : []),
24
- `Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
25
- `Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
26
- `Recent human messages: ${context.recentHumanCount}`,
27
- `Recent agent messages: ${context.recentAgentCount}`,
28
- `Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
29
- `Current agent streak started after a human message: ${context.currentAgentStreakStartedByHuman ? 'yes' : 'no'}`,
30
- ];
31
- }
32
- export function decideAutoReply(context, behavior) {
33
- const decision = evaluateParticipationPolicy(behavior ?? resolveAgentBehaviorPolicy(), {
34
- conversationType: context.conversationType,
35
- senderType: context.senderType,
36
- isOwner: context.isOwner,
37
- mentionedAgent: context.mentionedAgent,
38
- recentHumanCount: context.recentHumanCount,
39
- consecutiveAgentTurns: context.consecutiveAgentTurns,
40
- currentAgentStreakStartedByHuman: context.currentAgentStreakStartedByHuman,
41
- });
42
- return {
43
- allow: decision.allow,
44
- reason: decision.reason,
45
- };
46
- }
@@ -1,54 +0,0 @@
1
- /**
2
- * Startup recovery for inbound messages missed while the host was offline.
3
- *
4
- * The host persists a `lastInboundMessageId` cursor per conversation. On
5
- * startup we paginate `getMessagesPage` (newest-first pages, older pages via
6
- * its `before` message-id parameter) until the cursor is found or a hard
7
- * per-conversation bound is hit, then replay everything after the cursor.
8
- *
9
- * Claude also accepts legacy cursor candidates so it can migrate old
10
- * execution-mode-scoped state into one conversation-level recovery cursor.
11
- */
12
- export declare const STARTUP_RECOVERY_PAGE_SIZE = 25;
13
- export declare const STARTUP_RECOVERY_MAX_MESSAGES = 500;
14
- export interface StartupRecoveryMessage {
15
- id: string;
16
- senderId: string;
17
- createdAt?: string;
18
- metadata?: Record<string, unknown>;
19
- }
20
- export interface StartupRecoveryPage {
21
- messages: StartupRecoveryMessage[];
22
- }
23
- export type StartupRecoveryMode =
24
- /** Cursor found — `messages` is everything strictly after it. */
25
- 'after-cursor'
26
- /** Cursor present but not found within the bound — `messages` is the bounded recent window. */
27
- | 'truncated-window'
28
- /**
29
- * No usable cursor (fresh runtime file, or the cursor message no longer
30
- * exists in history) — only the newest inbound message is recovered, since
31
- * a full-history replay could fire mass duplicate turns.
32
- */
33
- | 'latest-only';
34
- export interface StartupRecoveryResult<TPage extends StartupRecoveryPage> {
35
- mode: StartupRecoveryMode;
36
- /** Cursor found in history and used for this recovery pass. */
37
- cursor: string | null;
38
- /** Missed inbound messages (own messages excluded), oldest first. */
39
- messages: TPage['messages'];
40
- /** First page fetched — reusable as hydration context for recovered turns. */
41
- newestPage: TPage;
42
- }
43
- /**
44
- * Startup recovery runs before the stream starts. The first SSE connection
45
- * therefore needs no second pass, while later reconnects still need catch-up.
46
- */
47
- export declare function createReconnectRecoveryGate(): () => boolean;
48
- export declare function collectMissedInboundMessages<TPage extends StartupRecoveryPage>(input: {
49
- fetchPage: (before?: string) => Promise<TPage>;
50
- cursor: string | null | undefined;
51
- cursorCandidates?: readonly string[];
52
- agentId: string;
53
- maxMessages?: number;
54
- }): Promise<StartupRecoveryResult<TPage>>;
@@ -1,102 +0,0 @@
1
- /**
2
- * Startup recovery for inbound messages missed while the host was offline.
3
- *
4
- * The host persists a `lastInboundMessageId` cursor per conversation. On
5
- * startup we paginate `getMessagesPage` (newest-first pages, older pages via
6
- * its `before` message-id parameter) until the cursor is found or a hard
7
- * per-conversation bound is hit, then replay everything after the cursor.
8
- *
9
- * Claude also accepts legacy cursor candidates so it can migrate old
10
- * execution-mode-scoped state into one conversation-level recovery cursor.
11
- */
12
- export const STARTUP_RECOVERY_PAGE_SIZE = 25;
13
- export const STARTUP_RECOVERY_MAX_MESSAGES = 500;
14
- /**
15
- * Startup recovery runs before the stream starts. The first SSE connection
16
- * therefore needs no second pass, while later reconnects still need catch-up.
17
- */
18
- export function createReconnectRecoveryGate() {
19
- let hasConnected = false;
20
- return () => {
21
- const shouldRecover = hasConnected;
22
- hasConnected = true;
23
- return shouldRecover;
24
- };
25
- }
26
- export async function collectMissedInboundMessages(input) {
27
- const maxMessages = input.maxMessages ?? STARTUP_RECOVERY_MAX_MESSAGES;
28
- const newestPage = await input.fetchPage();
29
- const collected = [...newestPage.messages];
30
- const seenIds = new Set(collected.map((message) => message.id));
31
- const candidateCursorIds = new Set(input.cursor
32
- ? [input.cursor]
33
- : (input.cursorCandidates ?? []).filter(Boolean));
34
- const foundCursorIds = new Set();
35
- const recordFoundCursors = (messages) => {
36
- for (const message of messages) {
37
- if (candidateCursorIds.has(message.id))
38
- foundCursorIds.add(message.id);
39
- }
40
- };
41
- const cursorSearchComplete = () => foundCursorIds.size > 0;
42
- const findNewestCursor = (messages) => messages.find((message) => candidateCursorIds.has(message.id))?.id ?? null;
43
- recordFoundCursors(collected);
44
- if (candidateCursorIds.size > 0) {
45
- while (!cursorSearchComplete() && collected.length < maxMessages) {
46
- // Pages are newest-first, so the last collected message is the oldest.
47
- const before = collected[collected.length - 1]?.id;
48
- if (!before)
49
- break;
50
- const page = await input.fetchPage(before);
51
- const fresh = page.messages.filter((message) => !seenIds.has(message.id));
52
- // No pagination progress (history exhausted, or the server ignored the
53
- // `before` cursor because that message was hard-deleted) — stop here.
54
- if (fresh.length === 0)
55
- break;
56
- for (const message of fresh)
57
- seenIds.add(message.id);
58
- collected.push(...fresh);
59
- recordFoundCursors(fresh);
60
- }
61
- }
62
- const resolvedCursor = cursorSearchComplete()
63
- ? findNewestCursor(collected)
64
- : null;
65
- // Preserve the API's deterministic newest-first order. Timestamps are
66
- // display data and may be equal or absent, so reversing wire order is the
67
- // only lossless oldest-first replay sequence.
68
- const ascending = [...collected].reverse();
69
- const completedSourceMessageIds = new Set(collected.flatMap((message) => {
70
- if (message.senderId !== input.agentId)
71
- return [];
72
- const metadata = message.metadata;
73
- if (metadata?.turnSemantics !== 'turn_complete')
74
- return [];
75
- return typeof metadata.sourceMessageId === 'string'
76
- ? [metadata.sourceMessageId]
77
- : [];
78
- }));
79
- const inboundOnly = (messages) => messages.filter((message) => message.senderId !== input.agentId && !completedSourceMessageIds.has(message.id));
80
- let mode;
81
- let missed;
82
- if (resolvedCursor) {
83
- const cursorIndex = ascending.findIndex((message) => message.id === resolvedCursor);
84
- mode = 'after-cursor';
85
- missed = inboundOnly(ascending.slice(cursorIndex + 1));
86
- }
87
- else if (candidateCursorIds.size > 0 && collected.length >= maxMessages) {
88
- mode = 'truncated-window';
89
- missed = inboundOnly(ascending.slice(-maxMessages));
90
- }
91
- else {
92
- mode = 'latest-only';
93
- missed = inboundOnly(ascending).slice(-1);
94
- }
95
- // Safe: `missed` only holds elements of pages returned by `fetchPage`.
96
- return {
97
- mode,
98
- cursor: resolvedCursor,
99
- messages: missed,
100
- newestPage,
101
- };
102
- }
@@ -1,16 +0,0 @@
1
- export interface StreamingBufferOptions {
2
- throttleMs: number;
3
- getText: () => string;
4
- setText: (text: string) => void;
5
- getTimer: () => ReturnType<typeof setTimeout> | null;
6
- setTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
7
- onWrite: (text: string) => void;
8
- schedule?: typeof setTimeout;
9
- cancel?: typeof clearTimeout;
10
- }
11
- export declare function createStreamingBuffer(options: StreamingBufferOptions): {
12
- append(delta: string): void;
13
- flush: () => void;
14
- clear(): void;
15
- replace(text: string): void;
16
- };
@@ -1,52 +0,0 @@
1
- export function createStreamingBuffer(options) {
2
- const schedule = options.schedule ?? setTimeout;
3
- const cancel = options.cancel ?? clearTimeout;
4
- const flush = () => {
5
- const timer = options.getTimer();
6
- if (timer) {
7
- cancel(timer);
8
- options.setTimer(null);
9
- }
10
- const text = options.getText();
11
- if (text) {
12
- options.onWrite(text);
13
- }
14
- };
15
- return {
16
- append(delta) {
17
- if (!delta)
18
- return;
19
- options.setText(`${options.getText()}${delta}`);
20
- if (options.getTimer())
21
- return;
22
- const timer = schedule(() => {
23
- options.setTimer(null);
24
- const text = options.getText();
25
- if (text) {
26
- options.onWrite(text);
27
- }
28
- }, options.throttleMs);
29
- options.setTimer(timer);
30
- },
31
- flush,
32
- clear() {
33
- const timer = options.getTimer();
34
- if (timer) {
35
- cancel(timer);
36
- options.setTimer(null);
37
- }
38
- options.setText('');
39
- },
40
- replace(text) {
41
- const timer = options.getTimer();
42
- if (timer) {
43
- cancel(timer);
44
- options.setTimer(null);
45
- }
46
- options.setText(text);
47
- if (text) {
48
- options.onWrite(text);
49
- }
50
- },
51
- };
52
- }