@canonmsg/claude-code-plugin 0.29.0 → 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.0",
4
+ "version": "0.29.2",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/README.md CHANGED
@@ -4,13 +4,14 @@ Connect Claude Code to [Canon](https://github.com/HeyBobChan/canon) — a messag
4
4
 
5
5
  ## Quick start
6
6
 
7
- The package includes a compatible Claude Code runtime. If `claude` is installed on `PATH`, or selected with `CANON_CLAUDE_CLI_PATH`, use Claude Code 2.1.220 or newer — the model picker lists whatever the CLI reports, so an older binary hides newer model families. A `PATH` install below that minimum is skipped in favour of the bundled runtime; a `CANON_CLAUDE_CLI_PATH` below it is a hard error.
7
+ The package includes a compatible Claude Code runtime. If `claude` is installed on `PATH`, or selected with `CANON_CLAUDE_CLI_PATH`, use Claude Code 2.1.220 or newer — the model picker lists whatever the CLI reports, so an older binary hides newer model families. Either source below that minimum is logged and skipped in favour of the bundled runtime, so a stale install costs model options rather than taking the host down.
8
8
 
9
9
  ```bash
10
10
  # Install
11
11
  npm install -g @canonmsg/claude-code-plugin
12
12
 
13
13
  # Register (approve in Canon app when prompted)
14
+ export CANON_ENVIRONMENT_ID=canon-prod-v1
14
15
  canon-register --name "My Claude" --description "My Claude Code agent" --phone "+15551234567"
15
16
 
16
17
  # Run
@@ -38,6 +39,10 @@ Public docs: <https://canonmail.com/agents/integrations>. Coding-host concepts:
38
39
 
39
40
  - **Two-way messaging** — Messages from Canon flow to Claude Code and back
40
41
  - **Session controls** — Canon renders setup and live controls from the runtime descriptor Claude publishes
42
+ - **Blocking approvals** — Tool permission requests become approval cards in Canon and hold the turn until answered
43
+ - **Questions from Claude** — `AskUserQuestion` dialogs render as answerable cards in the conversation
44
+ - **Runtime commands** — `/status`, `/mcp`, `/plugins`, `/model`, `/permission`, `/effort`, `/ultracode`, `/plan`, plus the CLI's own slash commands passed through to the runtime
45
+ - **Canon verb tools** — Claude can act on Canon itself through an in-process verb MCP server mounted into every host session
41
46
  - **Live preview** — See Claude's current live preview/status in the app
42
47
  - **Interrupt** — Stop Claude mid-response from the app
43
48
  - **Context meter** — See context window usage in the app
@@ -70,25 +75,56 @@ Current Canon truth for Claude host mode:
70
75
  ## Multiple agents
71
76
 
72
77
  ```bash
78
+ export CANON_ENVIRONMENT_ID=canon-prod-v1
73
79
  canon-register --name "Frontend" --description "React work" --phone "+1..." --profile frontend
74
80
  CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
75
81
  ```
76
82
 
83
+ With more than one registered profile, `CANON_AGENT` is required — the host refuses to guess and lists the available profiles instead.
84
+
77
85
  ## Channel mode (alternative)
78
86
 
79
- For a lighter integration without session controls, run Canon as a channel plugin inside your own Claude Code session. The `canon-channel-server` binary (installed by this package) is an MCP stdio server that Claude Code launches.
87
+ For a lighter integration without session controls, run Canon as a channel inside your own Claude Code session. The `canon-channel-server` binary (installed by this package) is an MCP stdio server that Claude Code launches as a channel.
88
+
89
+ Add it to the project's `.mcp.json` (or `~/.mcp.json` for global):
80
90
 
81
- Register the channel server with Claude Code's MCP config and provide your Canon API key via the `CANON_API_KEY` environment variable:
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "canon-channel": {
95
+ "command": "canon-channel-server"
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ Then start Claude Code with the channel loaded:
82
102
 
83
103
  ```bash
84
- # Register once per Claude Code project
85
- claude mcp add canon-channel canon-channel-server
104
+ CANON_API_KEY=agk_live_... CANON_ENVIRONMENT_ID=canon-prod-v1 \
105
+ claude --dangerously-load-development-channels server:canon-channel
106
+ ```
86
107
 
87
- # Then start Claude Code with your Canon key in the environment
88
- CANON_API_KEY=agk_live_... CANON_ENVIRONMENT_ID=canon-prod-v1 claude
108
+ The launch flag is what activates channel mode; plain `claude` treats the binary as an ordinary MCP server and never opens the channel. Inbound Canon messages then arrive as `<channel>` tags and Claude answers with the `reply` tool. Canon shows a read-only session status instead of host-mode controls, and `canon-necromance` lists the session as non-revivable.
109
+
110
+ If you registered a profile with `canon-register`, pin it instead of passing the key — the stored profile carries its own environment binding:
111
+
112
+ ```json
113
+ {
114
+ "mcpServers": {
115
+ "canon-channel": {
116
+ "command": "canon-channel-server",
117
+ "env": { "CANON_AGENT": "my-agent" }
118
+ }
119
+ }
120
+ }
89
121
  ```
90
122
 
91
- Channel mode forwards messages between Canon conversations and the embedded Claude Code session without managing session lifecycle. It is shown as non-revivable by `canon-necromance`. Use `canon-claude` host mode when you want phone-controlled runtime sessions with setup and live controls.
123
+ `CANON_PLUGIN_API_KEY` is accepted wherever `CANON_API_KEY` is; the bundled Claude Code plugin manifest sets it from user config. `CANON_ENVIRONMENT_ID` is still required with either variable.
124
+
125
+ `canon-setup` installs the bundled `/canon-register` and `/canon-configure` skills into `~/.claude/skills` and prints this MCP configuration.
126
+
127
+ Use `canon-claude` host mode when you want phone-controlled runtime sessions with setup and live controls.
92
128
 
93
129
  ## Development
94
130
 
@@ -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 {
@@ -1482,13 +1481,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1482
1481
  const deliveredMessageIds = chunkedFailure
1483
1482
  ? chunkedFailure.deliveredMessageIds
1484
1483
  : resume.deliveredMessageIds;
1485
- const failure = classifyFinalDeliveryFailure(cause, { chunked: willChunk });
1486
- if (failure === 'already-delivered') {
1487
- // The server's idempotency guard says this final is already durable.
1488
- markFinalTurnDelivered(deliverTurn);
1489
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply was already delivered under this id`);
1490
- return true;
1491
- }
1484
+ const failure = classifyFinalDeliveryFailure(cause);
1492
1485
  if (failure === 'permanent') {
1493
1486
  // Re-sending is pointless — the same request fails the same way. Surface
1494
1487
  // what can be surfaced and finish the turn instead of burning the retry
@@ -2089,6 +2082,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
2089
2082
  if (!shouldApplyClaudeEchoedSessionState({
2090
2083
  echoed: state,
2091
2084
  dispatching: session.dispatchingInput !== null,
2085
+ active: session.activeInput !== null,
2092
2086
  hasPendingFinalDelivery: Boolean(session.pendingFinalDelivery),
2093
2087
  })) {
2094
2088
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
@@ -2420,6 +2414,7 @@ export async function main() {
2420
2414
  agentId,
2421
2415
  clientType: 'claude-code',
2422
2416
  hostMode: true,
2417
+ rtdb,
2423
2418
  });
2424
2419
  let streamConnected = false;
2425
2420
  const hostAvailableExecutionModes = [
@@ -2460,6 +2455,7 @@ export async function main() {
2460
2455
  await publishHostSessionSnapshots({
2461
2456
  conversationIds,
2462
2457
  agentId,
2458
+ rtdb,
2463
2459
  clientType: 'claude-code',
2464
2460
  runtime: runtimeDescriptor,
2465
2461
  workspaceOptions,
@@ -2504,7 +2500,7 @@ export async function main() {
2504
2500
  console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
2505
2501
  return runtimeMetadata;
2506
2502
  });
2507
- await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
2503
+ await publishAgentRuntime(agentId, runtimeDescriptor, rtdb).catch((error) => {
2508
2504
  console.error('[canon-host] Failed to publish agent runtime:', error);
2509
2505
  });
2510
2506
  await publishSessionSnapshots(Array.from(knownConversationIds));
@@ -2865,7 +2861,7 @@ export async function main() {
2865
2861
  evictOldestIdle();
2866
2862
  }
2867
2863
  const creation = (async () => {
2868
- const config = await loadSessionConfig(conversationId, agentId);
2864
+ const config = await loadSessionConfig(conversationId, agentId, rtdb);
2869
2865
  const sessionExecutionMode = resolveSessionExecutionMode(config);
2870
2866
  const workspaceCwd = resolveWorkspaceCwd(config);
2871
2867
  const environment = prepareConversationEnvironment({
@@ -3119,40 +3115,21 @@ export async function main() {
3119
3115
  }
3120
3116
  return consumed;
3121
3117
  }
3122
- function persistedConversationCursors(conversationId) {
3123
- const canonicalCursor = loadRuntimeSessionState(runtimeId, {
3118
+ function persistedConversationCursor(conversationId) {
3119
+ return loadRuntimeSessionState(runtimeId, {
3124
3120
  conversationId,
3125
3121
  baseCwd: workingDir,
3126
3122
  workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3127
3123
  })?.lastInboundMessageId ?? null;
3128
- if (canonicalCursor)
3129
- return { canonicalCursor, legacyCursors: [] };
3130
- const legacyCursors = Object.values(readLocalRuntimeEntry(runtimeId)?.sessions ?? {})
3131
- .filter((state) => state.conversationId === conversationId)
3132
- .map((state) => state.lastInboundMessageId)
3133
- .filter((messageId) => Boolean(messageId));
3134
- return { canonicalCursor: null, legacyCursors: [...new Set(legacyCursors)] };
3135
3124
  }
3136
3125
  async function performMissedInboundRecovery() {
3137
3126
  for (const conversationId of knownConversationIds) {
3138
3127
  try {
3139
- const cursors = persistedConversationCursors(conversationId);
3140
3128
  const recovered = await collectMissedInboundMessages({
3141
3129
  fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
3142
- cursor: cursors.canonicalCursor,
3143
- cursorCandidates: cursors.legacyCursors,
3130
+ cursor: persistedConversationCursor(conversationId),
3144
3131
  agentId,
3145
3132
  });
3146
- if (!cursors.canonicalCursor
3147
- && recovered.cursor
3148
- && recovered.messages.length === 0) {
3149
- saveRuntimeSessionState(runtimeId, {
3150
- conversationId,
3151
- baseCwd: workingDir,
3152
- workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
3153
- lastInboundMessageId: recovered.cursor,
3154
- });
3155
- }
3156
3133
  if (recovered.mode === 'truncated-window') {
3157
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`);
3158
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();
@@ -165,6 +165,8 @@ export declare function shouldApplyClaudeEchoedSessionState(input: {
165
165
  echoed: ClaudeSessionRunState | undefined;
166
166
  /** An input is mid-dispatch: reserved by the host, not yet with the SDK. */
167
167
  dispatching: boolean;
168
+ /** The SDK has accepted a Canon input whose turn has not completed. */
169
+ active: boolean;
168
170
  /** A final is still being handed to Canon; the turn is not over. */
169
171
  hasPendingFinalDelivery: boolean;
170
172
  }): boolean;
@@ -478,34 +480,19 @@ export declare function composeClaudeFinalText(input: {
478
480
  streamedText?: string | null;
479
481
  failureNotice?: string | null;
480
482
  }): string | null;
481
- export type ClaudeFinalDeliveryFailure = 'retry' | 'permanent' | 'already-delivered';
483
+ export type ClaudeFinalDeliveryFailure = 'retry' | 'permanent';
482
484
  /**
483
485
  * How the host should react to a failed final-reply send.
484
486
  *
485
487
  * Core's `isRetryableCanonDeliveryError` is already right about what a retry
486
488
  * can fix (429, 5xx, transport errors); what the host lacked was the other
487
489
  * half — everything else is PERMANENT and must be surfaced instead of retried.
488
- * A 409 from the server's idempotency guard means a message already exists
489
- * under this id, so the reply is durable: that is a success, not a failure.
490
- *
491
- * A CHUNKED final is the exception (#616's rule, kept). A chunked send is N
492
- * sequential messages that abort on the first failure, so a 409 says only that
493
- * ONE part id is taken, with nothing to say which — and since the part ids are
494
- * a pure hash of agent + conversation + turnKey, an id can be taken by an
495
- * ENTIRELY DIFFERENT answer to the same inbound message, written by a previous
496
- * process. Reading that as "the final is delivered" would finalize the turn
497
- * with the tail never sent, so it stays 'permanent': a stops-short notice beats
498
- * silently dropping the end of an answer.
499
- *
500
- * Resume support does not soften this. Core absorbs the one conflict that IS
501
- * provably ours — the first part of a resumed attempt, whose write may have
502
- * landed before its response was lost — and reports every other one, so a
503
- * conflict reaching this function is precisely the case that must not be read
504
- * as success.
505
- */
506
- export declare function classifyFinalDeliveryFailure(error: unknown, options?: {
507
- chunked?: boolean;
508
- }): ClaudeFinalDeliveryFailure;
490
+ * Exact message-id replays return success with `created: false`. A 409 from the
491
+ * server's idempotency guard therefore means the stored payload differs from
492
+ * this final and is permanent; treating it as delivered would retire a turn
493
+ * whose answer was never accepted.
494
+ */
495
+ export declare function classifyFinalDeliveryFailure(error: unknown): ClaudeFinalDeliveryFailure;
509
496
  /**
510
497
  * The pending-delivery record for a final that has to be retried, carrying the
511
498
  * progress the next attempt needs.
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk';
3
- import { DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isCanonMessageIdConflict, isRetryableCanonDeliveryError, utf8ByteLength, VERB_LIMITS, } from '@canonmsg/core';
3
+ import { DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isRetryableCanonDeliveryError, utf8ByteLength, VERB_LIMITS, } from '@canonmsg/core';
4
4
  import { boundTrailBlockMap, buildTrailBlockId, buildUndeliverableFinalNotice as buildHostUndeliverableFinalNotice, normalizePlanStepStatus, normalizeTrailKey, PLAN_BLOCK_TITLE, renderPlanSteps, truncateFailureDetail, } from '@canonmsg/coding-agent-host';
5
5
  export function createClaudeInputEnvelope(input) {
6
6
  const sourceMessageId = input.sourceMessageId ?? null;
@@ -163,7 +163,7 @@ export function isClaudeTurnSlotReserved(state) {
163
163
  export function shouldApplyClaudeEchoedSessionState(input) {
164
164
  if (input.echoed !== 'idle')
165
165
  return true;
166
- return !input.dispatching && !input.hasPendingFinalDelivery;
166
+ return !input.dispatching && !input.active && !input.hasPendingFinalDelivery;
167
167
  }
168
168
  /** Claim the slot for an input about to be dispatched. Must precede any await. */
169
169
  export function reserveClaudeTurnSlot(session) {
@@ -775,28 +775,12 @@ export function composeClaudeFinalText(input) {
775
775
  * Core's `isRetryableCanonDeliveryError` is already right about what a retry
776
776
  * can fix (429, 5xx, transport errors); what the host lacked was the other
777
777
  * half — everything else is PERMANENT and must be surfaced instead of retried.
778
- * A 409 from the server's idempotency guard means a message already exists
779
- * under this id, so the reply is durable: that is a success, not a failure.
780
- *
781
- * A CHUNKED final is the exception (#616's rule, kept). A chunked send is N
782
- * sequential messages that abort on the first failure, so a 409 says only that
783
- * ONE part id is taken, with nothing to say which — and since the part ids are
784
- * a pure hash of agent + conversation + turnKey, an id can be taken by an
785
- * ENTIRELY DIFFERENT answer to the same inbound message, written by a previous
786
- * process. Reading that as "the final is delivered" would finalize the turn
787
- * with the tail never sent, so it stays 'permanent': a stops-short notice beats
788
- * silently dropping the end of an answer.
789
- *
790
- * Resume support does not soften this. Core absorbs the one conflict that IS
791
- * provably ours — the first part of a resumed attempt, whose write may have
792
- * landed before its response was lost — and reports every other one, so a
793
- * conflict reaching this function is precisely the case that must not be read
794
- * as success.
778
+ * Exact message-id replays return success with `created: false`. A 409 from the
779
+ * server's idempotency guard therefore means the stored payload differs from
780
+ * this final and is permanent; treating it as delivered would retire a turn
781
+ * whose answer was never accepted.
795
782
  */
796
- export function classifyFinalDeliveryFailure(error, options = {}) {
797
- if (isCanonMessageIdConflict(error) && !options.chunked) {
798
- return 'already-delivered';
799
- }
783
+ export function classifyFinalDeliveryFailure(error) {
800
784
  return isRetryableCanonDeliveryError(error) ? 'retry' : 'permanent';
801
785
  }
802
786
  /**
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.0",
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.2",
35
- "@canonmsg/agent-tools": "^0.3.1",
36
- "@canonmsg/coding-agent-host": "^0.3.0",
37
- "@canonmsg/core": "^8.1.0",
38
- "@canonmsg/rich-cards": "^0.8.5",
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,50 +1,59 @@
1
1
  ---
2
2
  name: canon-configure
3
- description: Configure an existing Canon agent API key
3
+ description: Use an existing Canon agent API key with Claude Code
4
4
  user-invocable: true
5
5
  allowed-tools:
6
6
  - Read
7
7
  - Write
8
8
  ---
9
9
 
10
- # Configure Canon API Key
10
+ # Use an existing Canon API key
11
11
 
12
- Add an existing Canon agent API key as a named profile.
12
+ Point Claude Code at a Canon agent key the user already has.
13
+
14
+ ## Hard rule
15
+
16
+ Never write or edit `~/.canon/agents.json`. A stored profile must carry the agent
17
+ identity **and** a complete environment-bound endpoint set (`agentId`,
18
+ `environmentId`, `baseUrl`, `streamUrl`, `rtdbUrl`, `firebaseApiKey`), and every
19
+ entry is validated in one pass on load — a single hand-written entry makes the
20
+ whole file unloadable for every Canon host on that machine. Only the register
21
+ CLI writes profiles.
13
22
 
14
23
  ## Steps
15
24
 
16
25
  1. Ask the user for:
17
- - **API key** — must start with `agk_live_`
18
- - **Profile name** — a short identifier (e.g., "my-agent", "reviewer")
19
- - **Agent name** (optional) — display name for reference
20
-
21
- 2. Validate the API key format — it must start with `agk_live_` and be non-empty.
26
+ - **API key** — starts with `agk_live_`
27
+ - **Canon environment** — `canon-prod-v1` for production, `canon-dev-v1` for dev
22
28
 
23
- 3. Read the existing profiles from `~/.canon/agents.json` (create if missing).
29
+ 2. Give them the launch command for the key. Nothing is stored; the key lives in
30
+ the environment of the process they start:
24
31
 
25
- 4. Add/update the profile:
26
- ```json
27
- {
28
- "<profile>": {
29
- "apiKey": "<key>",
30
- "agentId": "",
31
- "agentName": "<name or 'Unknown'>",
32
- "registeredAt": "<current ISO date>"
33
- }
34
- }
32
+ ```bash
33
+ CANON_API_KEY=<key> CANON_ENVIRONMENT_ID=<environment> canon-claude --cwd /path/to/project
35
34
  ```
36
35
 
37
- 5. Write the updated profiles to `~/.canon/agents.json`.
36
+ `CANON_ENVIRONMENT_ID` is required with a raw key — without it the host exits
37
+ before connecting. Sessions started this way are shown as
38
+ manual/non-revivable by `canon-necromance`, because no secret-bearing state is
39
+ persisted.
38
40
 
39
- 6. Tell the user: **"API key saved as profile '<profile>'. Restart Claude Code to connect."**
41
+ 3. If the user wants a stored, revivable profile instead, run `/canon-register`.
42
+ Registration is the only path that writes `~/.canon/agents.json`; re-running it
43
+ with an existing `--profile` name refreshes that profile's credential in place.
44
+
45
+ 4. Once a profile exists, it can be pinned for channel mode in `.mcp.json`:
40
46
 
41
- If they have multiple agents and want to pin one, add to `.mcp.json`:
42
47
  ```json
43
48
  {
44
49
  "mcpServers": {
45
50
  "canon-channel": {
51
+ "command": "canon-channel-server",
46
52
  "env": { "CANON_AGENT": "<profile>" }
47
53
  }
48
54
  }
49
55
  }
50
56
  ```
57
+
58
+ `CANON_AGENT` is required as soon as more than one profile exists — the host
59
+ refuses to guess between them.
@@ -18,7 +18,7 @@ Register a new Canon agent so it can send and receive messages. Each agent is sa
18
18
  - **Agent name** — The display name for the agent in Canon
19
19
  - **Description** — What the agent does (shown to users in Canon)
20
20
  - **Owner phone number** — The Canon account owner's phone number in E.164 format (e.g., +15551234567)
21
- - **Canon environment** — The trust-domain ID supplied by Canon (production defaults to `canon-prod-v1`)
21
+ - **Canon environment** — The trust-domain ID supplied by Canon (`canon-prod-v1` for production, `canon-dev-v1` for dev). There is no CLI default; the command fails without it.
22
22
  - **Profile name** (optional) — A short identifier for this agent (e.g., "reviewer", "notifier"). Defaults to a sanitized version of the agent name.
23
23
 
24
24
  2. Run the registration CLI:
@@ -26,18 +26,23 @@ Register a new Canon agent so it can send and receive messages. Each agent is sa
26
26
  canon-register --environment "<environment>" --name "<name>" --description "<description>" --phone "<phone>" --profile "<profile>"
27
27
  ```
28
28
 
29
+ `CANON_ENVIRONMENT_ID=<environment>` in the environment is equivalent to `--environment`.
30
+
29
31
  3. Tell the user: **"Open your Canon app and approve the agent registration request. Waiting for approval..."**
30
32
 
31
33
  The CLI will poll automatically for up to 5 minutes.
32
34
 
33
35
  4. On approval, the CLI saves the agent to `~/.canon/agents.json` automatically.
34
36
 
35
- 5. Tell the user: **"Agent registered! Run `canon-claude` to start. Or specify a project: `canon-claude --cwd /path/to/project`"**
37
+ 5. Tell the user: **"Agent registered! Start it with `CANON_AGENT=<profile> canon-claude`. Or specify a project: `CANON_AGENT=<profile> canon-claude --cwd /path/to/project`"**
38
+
39
+ `CANON_AGENT` is required once more than one profile exists — the host refuses to guess between them.
36
40
 
37
41
  ## Error handling
38
42
 
39
43
  - If the registration fails, show the error and ask the user to retry.
40
44
  - If status returns `"rejected"`, tell the user the registration was rejected by the owner.
41
45
  - If polling times out after 5 minutes, tell the user to try again later.
42
- - If the phone number format is wrong (must start with `+`, 8-16 digits), ask the user to re-enter it.
46
+ - If the server returns `Invalid phone number format`, the number could not be parsed as a real number; ask the user to re-enter it in full E.164 form (`+<country code><number>`).
47
+ - If the server returns `Owner phone number not found on platform`, the number is valid but no Canon account uses it; ask the user for the phone number of their Canon account.
43
48
  - If environment verification fails, do not retry against another URL. Confirm the environment ID and endpoint set with the user.
@@ -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
- }