@canonmsg/claude-code-plugin 0.12.8 → 0.13.1

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
@@ -66,9 +66,19 @@ CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
66
66
 
67
67
  ## Channel mode (alternative)
68
68
 
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).
69
+ 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.
70
70
 
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.
71
+ Register the channel server with Claude Code's MCP config and provide your Canon API key via the `CANON_API_KEY` environment variable:
72
+
73
+ ```bash
74
+ # Register once per Claude Code project
75
+ claude mcp add canon-channel canon-channel-server
76
+
77
+ # Then start Claude Code with your Canon key in the environment
78
+ CANON_API_KEY=agk_live_... claude
79
+ ```
80
+
81
+ 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
82
 
73
83
  ## Development
74
84
 
package/dist/host.js CHANGED
@@ -26,11 +26,11 @@ 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, 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, 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';
33
- import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, shouldDeliverClaudeFinal, } from './session-state.js';
33
+ import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
34
34
  const HELP = `canon-claude — run a local Claude Code agent host for Canon
35
35
 
36
36
  USAGE
@@ -154,26 +154,9 @@ function buildClaudeRuntimeDescriptor(input) {
154
154
  availability: ['always'],
155
155
  dispatch: { kind: 'open_details', target: 'effort' },
156
156
  },
157
- {
158
- id: 'stop',
159
- label: 'Stop',
160
- description: 'Interrupt the current Claude turn.',
161
- aliases: ['stop'],
162
- category: 'turn',
163
- placements: ['composer_slash', 'command_palette'],
164
- availability: ['busy'],
165
- dispatch: { kind: 'signal', signal: 'interrupt' },
166
- },
167
- {
168
- id: 'stop-and-clear-queue',
169
- label: 'Stop & clear queue',
170
- description: 'Interrupt the current Claude turn and drop queued Canon messages.',
171
- aliases: ['stop-clear', 'clear-queue'],
172
- category: 'turn',
173
- placements: ['composer_slash', 'command_palette', 'session_strip'],
174
- availability: ['busy_with_queue'],
175
- dispatch: { kind: 'signal', signal: 'stop_and_drop' },
176
- },
157
+ RUNTIME_STOP_ACTION,
158
+ RUNTIME_STOP_AND_DROP_ACTION,
159
+ RUNTIME_NEW_SESSION_ACTION,
177
160
  ],
178
161
  });
179
162
  }
@@ -802,6 +785,10 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
802
785
  function onStreamDelta(text) {
803
786
  streamingBuffer.append(text);
804
787
  }
788
+ function isActiveTurnInterrupted() {
789
+ const turnKey = session.activeInput?.turnKey;
790
+ return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
791
+ }
805
792
  function clearStreaming() {
806
793
  streamingBuffer.clear();
807
794
  runtimeState.clearStreaming(conversationId).catch(() => { });
@@ -822,15 +809,9 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
822
809
  if (session.closed)
823
810
  return;
824
811
  clearFinalDeliveryTimer();
825
- session.currentTurnId = null;
826
- session.currentTurnOpenedAt = null;
827
- session.lastAcceptedIntent = null;
828
- session.pendingFinalText = null;
829
- session.pendingFinalDelivery = null;
830
- session.activeInput = null;
831
- session.toolInProgress = false;
832
- session.turnState = 'idle';
812
+ resetClaudeCompletedTurnState(session);
833
813
  stopVisibleWorkSignal();
814
+ writeState();
834
815
  writeTurn();
835
816
  drainPendingInput();
836
817
  }
@@ -1100,6 +1081,11 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1100
1081
  }
1101
1082
  case 'stream_event': {
1102
1083
  const event = msg.event;
1084
+ if (isActiveTurnInterrupted()) {
1085
+ stopVisibleWorkSignal();
1086
+ client.setTyping(conversationId, false).catch(() => { });
1087
+ break;
1088
+ }
1103
1089
  if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1104
1090
  session.toolInProgress = true;
1105
1091
  session.turnState = 'tool';
@@ -1570,19 +1556,20 @@ export async function main() {
1570
1556
  isOwner: input.isOwner,
1571
1557
  });
1572
1558
  }
1573
- function closeSession(conversationId) {
1559
+ function closeSession(conversationId, options = {}) {
1574
1560
  const session = sessions.get(conversationId);
1575
1561
  if (!session)
1576
1562
  return;
1563
+ const saveResume = options.saveResume !== false;
1577
1564
  session.closed = true;
1578
1565
  if (session.typingKeepaliveTimer) {
1579
1566
  clearInterval(session.typingKeepaliveTimer);
1580
1567
  session.typingKeepaliveTimer = null;
1581
1568
  }
1582
1569
  releaseConversationEnvironment(session.environment);
1583
- // Save SDK session ID for future resume
1584
- if (session.sdkSessionId) {
1585
- savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
1570
+ const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
1571
+ if (saveResume && session.sdkSessionId) {
1572
+ savedSessionIds.set(resumeKey, session.sdkSessionId);
1586
1573
  saveRuntimeSessionState(runtimeId, {
1587
1574
  conversationId,
1588
1575
  baseCwd: session.environment.baseCwd,
@@ -1590,6 +1577,27 @@ export async function main() {
1590
1577
  claudeSessionId: session.sdkSessionId,
1591
1578
  });
1592
1579
  }
1580
+ else {
1581
+ const existingState = loadRuntimeSessionState(runtimeId, {
1582
+ conversationId,
1583
+ baseCwd: session.environment.baseCwd,
1584
+ executionMode: session.environment.mode,
1585
+ });
1586
+ savedSessionIds.delete(resumeKey);
1587
+ clearRuntimeSessionState(runtimeId, {
1588
+ conversationId,
1589
+ baseCwd: session.environment.baseCwd,
1590
+ executionMode: session.environment.mode,
1591
+ });
1592
+ if (existingState?.lastInboundMessageId) {
1593
+ saveRuntimeSessionState(runtimeId, {
1594
+ conversationId,
1595
+ baseCwd: session.environment.baseCwd,
1596
+ executionMode: session.environment.mode,
1597
+ lastInboundMessageId: existingState.lastInboundMessageId,
1598
+ });
1599
+ }
1600
+ }
1593
1601
  if (session.streamingTimer)
1594
1602
  clearTimeout(session.streamingTimer);
1595
1603
  session.streamingText = '';
@@ -1610,7 +1618,22 @@ export async function main() {
1610
1618
  }
1611
1619
  catch { }
1612
1620
  sessions.delete(conversationId);
1613
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session suspended (sessionId saved for resume)`);
1621
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
1622
+ }
1623
+ async function resetRuntimeSession(session) {
1624
+ const conversationId = session.conversationId;
1625
+ const droppedInputs = session.pendingInputs.splice(0);
1626
+ await markSessionPendingInputsRejected(conversationId, droppedInputs);
1627
+ if (session.activeInput) {
1628
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
1629
+ }
1630
+ session.pendingFinalText = null;
1631
+ session.pendingFinalDelivery = null;
1632
+ session.turnState = 'interrupted';
1633
+ if (session.state.state === 'running') {
1634
+ await session.query.interrupt().catch(() => { });
1635
+ }
1636
+ closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
1614
1637
  }
1615
1638
  async function markSessionPendingInputsRejected(conversationId, inputs) {
1616
1639
  await Promise.all(inputs.map((input) => {
@@ -2054,10 +2077,17 @@ export async function main() {
2054
2077
  if (sig && typeof sig === 'object') {
2055
2078
  const s = sig;
2056
2079
  const ts = s.updatedAt ?? 0;
2057
- if ((s.type === 'interrupt' || s.type === 'stop_and_drop') && ts > (lastSeenSignal.get(convoId) ?? 0)) {
2080
+ if ((s.type === 'interrupt' || s.type === 'stop_and_drop' || s.type === 'new_session')
2081
+ && ts > (lastSeenSignal.get(convoId) ?? 0)) {
2058
2082
  lastSeenSignal.set(convoId, ts);
2059
2083
  const session = sessions.get(convoId);
2060
2084
  if (session && !session.closed) {
2085
+ if (s.type === 'new_session') {
2086
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
2087
+ await resetRuntimeSession(session);
2088
+ await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
2089
+ continue;
2090
+ }
2061
2091
  const isActive = session.state.state === 'running';
2062
2092
  if (!isActive && (s.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
2063
2093
  await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
@@ -1,5 +1,5 @@
1
1
  import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
- import type { DeliveryIntent, ModelOption } from '@canonmsg/core';
2
+ import type { DeliveryIntent, ModelOption, TurnLifecycleState } from '@canonmsg/core';
3
3
  export type ClaudeInputKind = 'seed' | 'canon';
4
4
  export interface ClaudeInputEnvelope {
5
5
  kind: ClaudeInputKind;
@@ -19,6 +19,19 @@ export interface ClaudePendingFinalDelivery {
19
19
  text: string;
20
20
  messageId: string;
21
21
  }
22
+ export interface ClaudeCompletedTurnState {
23
+ state: {
24
+ state?: 'idle' | 'running' | 'requires_action';
25
+ };
26
+ pendingFinalText: string | null;
27
+ pendingFinalDelivery: ClaudePendingFinalDelivery | null;
28
+ activeInput: ClaudeInputEnvelope | null;
29
+ toolInProgress: boolean;
30
+ turnState: TurnLifecycleState;
31
+ currentTurnId: string | null;
32
+ currentTurnOpenedAt: number | null;
33
+ lastAcceptedIntent: DeliveryIntent | null;
34
+ }
22
35
  export declare function createClaudeInputEnvelope(input: {
23
36
  kind: ClaudeInputKind;
24
37
  msg: SDKUserMessage;
@@ -36,6 +49,7 @@ export declare function shouldDeliverClaudeFinal(input: {
36
49
  finalizedTurnKeys: ReadonlySet<string>;
37
50
  interruptedTurnKeys: ReadonlySet<string>;
38
51
  }): boolean;
52
+ export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
39
53
  export declare function mergeClaudeModelOptions(input: {
40
54
  previous: ReadonlyArray<ModelOption>;
41
55
  next: ReadonlyArray<ModelOption>;
@@ -29,6 +29,17 @@ export function shouldDeliverClaudeFinal(input) {
29
29
  return false;
30
30
  return true;
31
31
  }
32
+ export function resetClaudeCompletedTurnState(session) {
33
+ session.state.state = 'idle';
34
+ session.currentTurnId = null;
35
+ session.currentTurnOpenedAt = null;
36
+ session.lastAcceptedIntent = null;
37
+ session.pendingFinalText = null;
38
+ session.pendingFinalDelivery = null;
39
+ session.activeInput = null;
40
+ session.toolInProgress = false;
41
+ session.turnState = 'idle';
42
+ }
32
43
  export function mergeClaudeModelOptions(input) {
33
44
  const byValue = new Map();
34
45
  for (const option of input.previous) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.12.8",
3
+ "version": "0.13.1",
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.1.4",
35
- "@canonmsg/core": "^0.15.4",
34
+ "@canonmsg/agent-sdk": "^1.2.1",
35
+ "@canonmsg/core": "^0.16.0",
36
36
  "@modelcontextprotocol/sdk": "^1.12.0"
37
37
  },
38
38
  "engines": {