@canonmsg/claude-code-plugin 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ canon-register --name "My Claude" --description "My Claude Code agent" --phone "
15
15
  canon-claude
16
16
  ```
17
17
 
18
- Send a message to your agent in the Canon app. Claude Code responds with full capabilities (file read/write, bash, etc.). Keep the `canon-claude` terminal open; closing it takes this local agent offline.
18
+ Send a message to your agent in the Canon app. Claude Code responds with full capabilities (file read/write, bash, etc.). `canon-claude` is the local agent process. Keep that terminal open while you want Canon to reach the agent. Closing it, logging out, rebooting, or sleeping long enough to stop the process takes this local agent offline until you revive it.
19
19
 
20
20
  After a restart or closed terminal, use the shared local manager to list and revive all recorded local agents:
21
21
 
@@ -25,6 +25,8 @@ canon-necromance
25
25
  canon-necromance revive frontend
26
26
  ```
27
27
 
28
+ Public docs: <https://canonmail.com/agents/integrations>. Coding-host concepts: <https://canonmail.com/agents/coding-agents>.
29
+
28
30
  ## Features
29
31
 
30
32
  - **Two-way messaging** — Messages from Canon flow to Claude Code and back
@@ -66,9 +68,19 @@ CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
66
68
 
67
69
  ## Channel mode (alternative)
68
70
 
69
- For a lighter integration without session controls, run Canon as a channel plugin inside your own Claude Code session. See [full setup guide](../../docs/claude-code-setup.md#channel-mode-alternative).
71
+ 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.
72
+
73
+ Register the channel server with Claude Code's MCP config and provide your Canon API key via the `CANON_API_KEY` environment variable:
74
+
75
+ ```bash
76
+ # Register once per Claude Code project
77
+ claude mcp add canon-channel canon-channel-server
78
+
79
+ # Then start Claude Code with your Canon key in the environment
80
+ CANON_API_KEY=agk_live_... claude
81
+ ```
70
82
 
71
- Channel mode is embedded inside Claude Code and is shown as non-revivable by `canon-necromance`. Use `canon-claude` host mode when you want phone-controlled runtime sessions.
83
+ 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.
72
84
 
73
85
  ## Development
74
86
 
package/dist/host.js CHANGED
@@ -26,7 +26,7 @@ import { execFileSync } from 'node:child_process';
26
26
  import { existsSync } from 'node:fs';
27
27
  import { query, } from '@anthropic-ai/claude-agent-sdk';
28
28
  import { isAnthropicImageAttachment, materializeMessageMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
29
- import { CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
29
+ import { CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
30
30
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
31
31
  import { createStreamingBuffer } from './streaming-buffer.js';
32
32
  import { runCli } from './cli-entry.js';
@@ -51,7 +51,10 @@ EXAMPLES
51
51
  canon-claude --cwd ~/dev/canon
52
52
  canon-claude --cwd ~/dev/canon --workspace-root ~/dev
53
53
 
54
- Keep this terminal open while you want Canon to reach the agent.`;
54
+ Keep this terminal open while you want Canon to reach the agent. Closing it,
55
+ logging out, rebooting, or sleeping long enough to stop the process takes the
56
+ local agent offline until you revive it. Docs:
57
+ https://canonmail.com/agents/integrations`;
55
58
  let workingDir = process.cwd();
56
59
  let workspaceOptions = [];
57
60
  let workspaceRoots = [];
@@ -785,6 +788,10 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
785
788
  function onStreamDelta(text) {
786
789
  streamingBuffer.append(text);
787
790
  }
791
+ function isActiveTurnInterrupted() {
792
+ const turnKey = session.activeInput?.turnKey;
793
+ return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
794
+ }
788
795
  function clearStreaming() {
789
796
  streamingBuffer.clear();
790
797
  runtimeState.clearStreaming(conversationId).catch(() => { });
@@ -1077,6 +1084,11 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1077
1084
  }
1078
1085
  case 'stream_event': {
1079
1086
  const event = msg.event;
1087
+ if (isActiveTurnInterrupted()) {
1088
+ stopVisibleWorkSignal();
1089
+ client.setTyping(conversationId, false).catch(() => { });
1090
+ break;
1091
+ }
1080
1092
  if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1081
1093
  session.toolInProgress = true;
1082
1094
  session.turnState = 'tool';
@@ -1313,10 +1325,12 @@ export async function main() {
1313
1325
  initRTDBAuth(client);
1314
1326
  let agentId;
1315
1327
  let ownerId = null;
1328
+ let ownerName = null;
1316
1329
  try {
1317
1330
  const ctx = await client.getAgentMe();
1318
1331
  agentId = ctx.agentId;
1319
1332
  ownerId = ctx.ownerId;
1333
+ ownerName = ctx.ownerName;
1320
1334
  console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
1321
1335
  }
1322
1336
  catch {
@@ -1381,6 +1395,8 @@ export async function main() {
1381
1395
  };
1382
1396
  const conversationCache = new Map();
1383
1397
  const knownConversationIds = new Set();
1398
+ const promptedGroupContextConversationIds = new Set();
1399
+ const pendingMembershipChanges = new Map();
1384
1400
  let lastKnownConversationRefreshAt = 0;
1385
1401
  const publishRuntimeHeartbeat = async () => {
1386
1402
  heartbeatLocalRuntimeEntry(runtimeId, {
@@ -1533,18 +1549,65 @@ export async function main() {
1533
1549
  }
1534
1550
  lastKnownConversationRefreshAt = Date.now();
1535
1551
  }
1552
+ function handleConversationUpdated(payload) {
1553
+ const rawMemberIds = payload.changes.memberIds;
1554
+ if (!Array.isArray(rawMemberIds))
1555
+ return;
1556
+ const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
1557
+ const cached = conversationCache.get(payload.conversationId);
1558
+ const membershipChange = payload.membershipChange
1559
+ ?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
1560
+ if (cached) {
1561
+ conversationCache.set(payload.conversationId, {
1562
+ ...cached,
1563
+ memberIds,
1564
+ });
1565
+ }
1566
+ if (membershipChange) {
1567
+ pendingMembershipChanges.set(payload.conversationId, membershipChange);
1568
+ }
1569
+ if (!memberIds.includes(agentId)) {
1570
+ knownConversationIds.delete(payload.conversationId);
1571
+ conversationCache.delete(payload.conversationId);
1572
+ }
1573
+ }
1574
+ function getGroupContextMode(conversationId, conversation) {
1575
+ if (conversation?.type !== 'group')
1576
+ return undefined;
1577
+ if (pendingMembershipChanges.has(conversationId))
1578
+ return 'membership_change';
1579
+ if (!promptedGroupContextConversationIds.has(conversationId))
1580
+ return 'initial';
1581
+ return undefined;
1582
+ }
1583
+ function markGroupContextModeUsed(conversationId, mode) {
1584
+ if (!mode)
1585
+ return;
1586
+ promptedGroupContextConversationIds.add(conversationId);
1587
+ if (mode === 'membership_change') {
1588
+ pendingMembershipChanges.delete(conversationId);
1589
+ }
1590
+ }
1536
1591
  async function loadHydratedInboundContext(input) {
1537
1592
  const [conversation, page] = await Promise.all([
1538
1593
  getConversationMeta(input.conversationId),
1539
- client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
1594
+ input.hydratedPage
1595
+ ? Promise.resolve(input.hydratedPage)
1596
+ : client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
1540
1597
  ]);
1541
1598
  return buildHydratedInboundContext({
1542
1599
  agentId,
1543
1600
  conversation,
1544
1601
  page,
1602
+ activeSelfContextId: input.activeSelfContextId,
1603
+ selfContexts: input.selfContexts,
1545
1604
  message: input.message,
1546
1605
  senderName: input.senderName,
1547
1606
  isOwner: input.isOwner,
1607
+ ownerId,
1608
+ ownerName,
1609
+ membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
1610
+ groupContextMode: getGroupContextMode(input.conversationId, conversation),
1548
1611
  });
1549
1612
  }
1550
1613
  function closeSession(conversationId, options = {}) {
@@ -1779,15 +1842,18 @@ export async function main() {
1779
1842
  message: m,
1780
1843
  senderName: sender,
1781
1844
  isOwner,
1845
+ hydratedPage: input.hydratedPage,
1782
1846
  });
1783
1847
  const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
1784
- const selfContexts = input.hydratedPage?.selfContexts ?? hydrated.selfContexts;
1848
+ const activeSelfContextId = hydrated.activeSelfContextId;
1849
+ const selfContexts = hydrated.selfContexts;
1785
1850
  const participantContext = hydrated.participantContext;
1786
1851
  const autoReply = decideAutoReply(participantContext, behavior);
1787
1852
  if (!autoReply.allow) {
1788
1853
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
1789
1854
  return;
1790
1855
  }
1856
+ markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
1791
1857
  let session;
1792
1858
  try {
1793
1859
  session = await getOrCreateSession(input.conversationId);
@@ -1797,7 +1863,7 @@ export async function main() {
1797
1863
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1798
1864
  console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
1799
1865
  await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1800
- ...(selfContexts[0]?.id ? { selfContextId: selfContexts[0].id } : {}),
1866
+ ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
1801
1867
  metadata: {
1802
1868
  turnSemantics: 'turn_complete',
1803
1869
  turnComplete: true,
@@ -1807,7 +1873,7 @@ export async function main() {
1807
1873
  return;
1808
1874
  }
1809
1875
  const turnMetadata = normalizeTurnMetadata(m.metadata);
1810
- session.activeSelfContextId = selfContexts[0]?.id ?? null;
1876
+ session.activeSelfContextId = activeSelfContextId;
1811
1877
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
1812
1878
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
1813
1879
  const promptText = buildCanonPrompt({
@@ -1925,19 +1991,19 @@ export async function main() {
1925
1991
  message: m,
1926
1992
  senderName: sender,
1927
1993
  isOwner,
1994
+ activeSelfContextId: payload.activeSelfContextId,
1995
+ selfContexts: payload.selfContexts,
1928
1996
  });
1929
1997
  const behavior = payload.behavior ?? hydrated.behavior;
1930
- const selfContexts = hydrated.hydratedFromPage
1931
- ? hydrated.selfContexts
1932
- : Array.isArray(payload.selfContexts)
1933
- ? payload.selfContexts
1934
- : hydrated.selfContexts;
1998
+ const activeSelfContextId = hydrated.activeSelfContextId;
1999
+ const selfContexts = hydrated.selfContexts;
1935
2000
  const participantContext = hydrated.participantContext;
1936
2001
  const autoReply = decideAutoReply(participantContext, behavior);
1937
2002
  if (!autoReply.allow) {
1938
2003
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
1939
2004
  return;
1940
2005
  }
2006
+ markGroupContextModeUsed(convoId, participantContext.groupContextMode);
1941
2007
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
1942
2008
  let session;
1943
2009
  try {
@@ -1948,7 +2014,7 @@ export async function main() {
1948
2014
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1949
2015
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
1950
2016
  await client.sendMessage(convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1951
- ...(selfContexts[0]?.id ? { selfContextId: selfContexts[0].id } : {}),
2017
+ ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
1952
2018
  metadata: {
1953
2019
  turnSemantics: 'turn_complete',
1954
2020
  turnComplete: true,
@@ -1958,7 +2024,7 @@ export async function main() {
1958
2024
  return;
1959
2025
  }
1960
2026
  const turnMetadata = normalizeTurnMetadata(m.metadata);
1961
- session.activeSelfContextId = selfContexts[0]?.id ?? null;
2027
+ session.activeSelfContextId = activeSelfContextId;
1962
2028
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
1963
2029
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
1964
2030
  const promptText = buildCanonPrompt({
@@ -1987,6 +2053,9 @@ export async function main() {
1987
2053
  }
1988
2054
  })();
1989
2055
  },
2056
+ onConversationUpdated: (payload) => {
2057
+ handleConversationUpdated(payload);
2058
+ },
1990
2059
  onConnected: () => {
1991
2060
  streamConnected = true;
1992
2061
  void publishRuntimeHeartbeat();
@@ -1,4 +1,4 @@
1
- import { type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
1
+ import { type CanonGroupContext, type CanonGroupContextMode, type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
2
2
  export interface InboundParticipantContext {
3
3
  conversationType: CanonConversation['type'] | 'unknown';
4
4
  memberCount: number | null;
@@ -6,6 +6,8 @@ export interface InboundParticipantContext {
6
6
  senderName: string;
7
7
  isOwner: boolean;
8
8
  mentionedAgent: boolean;
9
+ groupContext?: CanonGroupContext;
10
+ groupContextMode?: CanonGroupContextMode;
9
11
  recentSenderTypes: Array<'human' | 'ai_agent'>;
10
12
  recentHumanCount: number;
11
13
  recentAgentCount: number;
@@ -1,4 +1,4 @@
1
- import { evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
1
+ import { buildCompactGroupContextLines, evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
2
2
  function formatRecentSenders(senderTypes) {
3
3
  if (senderTypes.length === 0)
4
4
  return 'none';
@@ -18,6 +18,9 @@ export function buildInboundContextLines(context) {
18
18
  `Latest sender name: ${context.senderName}`,
19
19
  `Latest sender type: ${context.senderType}`,
20
20
  `Conversation type: ${conversationTypeLabel}`,
21
+ ...(context.groupContext && context.groupContextMode
22
+ ? buildCompactGroupContextLines(context.groupContext, context.groupContextMode)
23
+ : []),
21
24
  `Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
22
25
  `Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
23
26
  `Recent human messages: ${context.recentHumanCount}`,
package/dist/register.js CHANGED
@@ -107,7 +107,9 @@ export async function main() {
107
107
  console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
108
108
  console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
109
109
  console.log('Start it with: CANON_AGENT=' + profileName + ' canon-claude --cwd /path/to/project');
110
- console.log('Keep that terminal open. Closing it takes this local agent offline.');
110
+ console.log('Keep that terminal open while you want Canon to reach the agent.');
111
+ console.log('Closing it, logging out, rebooting, or sleeping long enough to stop the process takes this local agent offline until you revive it.');
112
+ console.log('Docs: https://canonmail.com/agents/integrations');
111
113
  break;
112
114
  }
113
115
  case 'rejected':
package/dist/server.js CHANGED
@@ -447,6 +447,9 @@ async function handleInboundMessage(payload) {
447
447
  }
448
448
  }
449
449
  if (Array.isArray(payload.selfContexts) && payload.selfContexts.length > 0) {
450
+ if (payload.activeSelfContextId) {
451
+ meta.active_self_context_id = payload.activeSelfContextId;
452
+ }
450
453
  meta.self_context_ids = payload.selfContexts
451
454
  .map((selfContext) => selfContext.id)
452
455
  .join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
34
- "@canonmsg/agent-sdk": "^1.2.0",
35
- "@canonmsg/core": "^0.16.0",
34
+ "@canonmsg/agent-sdk": "^1.3.0",
35
+ "@canonmsg/core": "^0.17.0",
36
36
  "@modelcontextprotocol/sdk": "^1.12.0"
37
37
  },
38
38
  "engines": {