@canonmsg/claude-code-plugin 0.9.5 โ†’ 0.9.6

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.
@@ -37,6 +37,12 @@ interface HostWorkspaceResolverOption {
37
37
  id: string;
38
38
  cwd: string;
39
39
  }
40
+ export declare const HOST_ADMISSION_ACTION_CAPABILITIES: Readonly<{
41
+ canStartDirectConversation: false;
42
+ canSendContactRequest: false;
43
+ canApprovePendingContactRequests: false;
44
+ canRejectPendingContactRequests: false;
45
+ }>;
40
46
  export declare function buildCanonHostPrompt(input: {
41
47
  hostLabel: string;
42
48
  content: string;
@@ -82,6 +88,21 @@ export declare function buildHydratedInboundContext(input: {
82
88
  hydratedFromPage: boolean;
83
89
  };
84
90
  export declare function publishHostAgentRuntime(agentId: string, clientType: AgentClientType, runtime: AgentRuntime): Promise<void>;
91
+ export declare function publishHostSessionSnapshots(input: {
92
+ conversationIds: string[];
93
+ agentId: string;
94
+ clientType: AgentClientType;
95
+ runtime: AgentRuntime;
96
+ workspaceOptions: HostWorkspaceResolverOption[];
97
+ defaultCwd: string;
98
+ liveSessionConfigByConversation?: ReadonlyMap<string, {
99
+ model?: string;
100
+ permissionMode?: string;
101
+ workspaceId?: string;
102
+ executionMode?: SessionWorkspaceConfig['executionMode'];
103
+ executionBranch?: string | null;
104
+ }>;
105
+ }): Promise<void>;
85
106
  export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null;
86
107
  export declare function loadHostSessionConfig<TExtra extends string = never>(input: {
87
108
  conversationId: string;
@@ -11,7 +11,13 @@
11
11
  * behavior here, update that copy too and adjust the shared golden
12
12
  * fixture test (`packages/claude-code-plugin/src/host-runtime.test.ts`).
13
13
  */
14
- import { buildBehaviorPolicyLines, buildParticipationHistorySnapshot, buildWorkSessionsPromptLines, mergeWorkSessionContexts, normalizeOptionalString, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, rtdbRead, rtdbWrite, } from '@canonmsg/core';
14
+ import { buildAgentSessionSnapshot, buildConversationWorktreeSpec, buildBehaviorPolicyLines, buildParticipationHistorySnapshot, buildWorkSessionsPromptLines, mergeWorkSessionContexts, normalizeOptionalString, patchAgentSessionSnapshot, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, rtdbRead, rtdbWrite, } from '@canonmsg/core';
15
+ export const HOST_ADMISSION_ACTION_CAPABILITIES = Object.freeze({
16
+ canStartDirectConversation: false,
17
+ canSendContactRequest: false,
18
+ canApprovePendingContactRequests: false,
19
+ canRejectPendingContactRequests: false,
20
+ });
15
21
  export function buildCanonHostPrompt(input) {
16
22
  const resolvedWorkSessions = mergeWorkSessionContexts(input.workSession, input.workSessions);
17
23
  return [
@@ -70,7 +76,21 @@ function describeContactCard(card) {
70
76
  if (card.about)
71
77
  parts.push(`about: ${card.about}`);
72
78
  const identity = `๐Ÿ“‡ Contact card: "${card.displayName}" (${parts.join(' ยท ')}).`;
73
- const hint = `This card is informational only in host mode. Canon does not currently expose a host-side tool here for starting a new direct conversation or sending a contact request to userId ${card.userId}.`;
79
+ const missingCapabilities = [
80
+ !HOST_ADMISSION_ACTION_CAPABILITIES.canStartDirectConversation
81
+ ? 'start a direct conversation'
82
+ : null,
83
+ !HOST_ADMISSION_ACTION_CAPABILITIES.canSendContactRequest
84
+ ? 'send a contact request'
85
+ : null,
86
+ !HOST_ADMISSION_ACTION_CAPABILITIES.canApprovePendingContactRequests
87
+ ? 'approve pending requests'
88
+ : null,
89
+ !HOST_ADMISSION_ACTION_CAPABILITIES.canRejectPendingContactRequests
90
+ ? 'reject pending requests'
91
+ : null,
92
+ ].filter(Boolean).join(', ');
93
+ const hint = `This host can inspect the card, but Canon admission actions are missing here. Missing capabilities: ${missingCapabilities}. Use another Canon surface for userId ${card.userId}.`;
74
94
  return `${identity}\n${hint}`;
75
95
  }
76
96
  function describeAttachment(attachment, materialized) {
@@ -117,6 +137,64 @@ export async function publishHostAgentRuntime(agentId, clientType, runtime) {
117
137
  updatedAt: { '.sv': 'timestamp' },
118
138
  });
119
139
  }
140
+ export async function publishHostSessionSnapshots(input) {
141
+ if (input.conversationIds.length === 0) {
142
+ return;
143
+ }
144
+ await Promise.all(input.conversationIds.map(async (conversationId) => {
145
+ const persistedConfig = await loadHostSessionConfig({
146
+ conversationId,
147
+ agentId: input.agentId,
148
+ extraStringFields: ['permissionMode'],
149
+ });
150
+ const liveConfig = input.liveSessionConfigByConversation?.get(conversationId) ?? null;
151
+ const mergedConfig = {
152
+ ...(persistedConfig ?? {}),
153
+ ...(liveConfig ?? {}),
154
+ };
155
+ const snapshot = buildAgentSessionSnapshot({
156
+ conversationId,
157
+ agentId: input.agentId,
158
+ runtime: {
159
+ ...input.runtime,
160
+ clientType: input.clientType,
161
+ hostMode: true,
162
+ },
163
+ sessionConfig: {
164
+ ...(mergedConfig.model ? { model: mergedConfig.model } : {}),
165
+ ...(mergedConfig.permissionMode ? { permissionMode: mergedConfig.permissionMode } : {}),
166
+ ...(mergedConfig.workspaceId ? { workspaceId: mergedConfig.workspaceId } : {}),
167
+ ...(mergedConfig.executionMode ? { executionMode: mergedConfig.executionMode } : {}),
168
+ },
169
+ lastHeartbeatAt: undefined,
170
+ });
171
+ let executionBranch = liveConfig?.executionBranch ?? null;
172
+ if (!executionBranch && snapshot.executionMode === 'worktree' && snapshot.workspaceId) {
173
+ const workspace = input.workspaceOptions.find((option) => option.id === snapshot.workspaceId);
174
+ if (workspace) {
175
+ executionBranch = buildConversationWorktreeSpec({
176
+ agentId: input.agentId,
177
+ conversationId,
178
+ workspaceCwd: workspace.cwd,
179
+ }).branch;
180
+ }
181
+ }
182
+ return patchAgentSessionSnapshot(conversationId, input.agentId, {
183
+ clientType: input.clientType,
184
+ hostMode: true,
185
+ model: snapshot.model ?? null,
186
+ permissionMode: snapshot.permissionMode ?? null,
187
+ workspaceId: snapshot.workspaceId ?? null,
188
+ executionMode: snapshot.executionMode ?? null,
189
+ executionBranch,
190
+ modelOptions: snapshot.modelOptions,
191
+ permissionModeOptions: snapshot.permissionModeOptions,
192
+ workspaceOptions: snapshot.workspaceOptions,
193
+ availableExecutionModes: snapshot.availableExecutionModes,
194
+ lastHeartbeatAt: { '.sv': 'timestamp' },
195
+ });
196
+ }));
197
+ }
120
198
  export function readHostSessionConfig(raw, extraStringFields = []) {
121
199
  const baseConfig = readSessionWorkspaceConfig(raw);
122
200
  if (!raw || typeof raw !== 'object') {
package/dist/host.js CHANGED
@@ -26,8 +26,8 @@ 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, buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, writeTurnState, releaseConversationEnvironment, } from '@canonmsg/core';
30
- import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
29
+ import { CLAUDE_PERMISSION_MODE_OPTIONS, buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeSessionState, clearSessionState, writeTurnState, releaseConversationEnvironment, } from '@canonmsg/core';
30
+ import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
31
31
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
32
32
  import { createStreamingBuffer } from './streaming-buffer.js';
33
33
  import { runCli } from './cli-entry.js';
@@ -222,7 +222,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
222
222
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
223
223
  let session;
224
224
  const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
225
- function waitForReply() {
225
+ function waitForReply(type, requestId) {
226
226
  // Keep session alive while waiting for user input
227
227
  session.lastActivity = Date.now();
228
228
  return new Promise((resolve, reject) => {
@@ -231,6 +231,8 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
231
231
  reject(new Error('Timed out waiting for user reply'));
232
232
  }, REPLY_TIMEOUT_MS);
233
233
  session.pendingReply = {
234
+ type,
235
+ requestId,
234
236
  resolve: (metadata) => {
235
237
  clearTimeout(timer);
236
238
  resolve(metadata);
@@ -260,17 +262,16 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
260
262
  try {
261
263
  const questionId = randomUUID();
262
264
  const questions = input.questions ?? [];
263
- await client.sendMessage(conversationId, '', {
265
+ const questionCard = buildQuestionRequest(questionId, questions);
266
+ await client.sendMessage(conversationId, questionCard.text, {
264
267
  metadata: {
265
- type: 'claude_question',
266
- questionId,
267
- questions,
268
+ ...questionCard.metadata,
268
269
  turnSemantics: 'control',
269
270
  replyBehavior: 'suppress_auto_reply',
270
271
  },
271
272
  });
272
273
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Question sent (${questionId.slice(0, 8)})`);
273
- const reply = await waitForReply();
274
+ const reply = await waitForReply('question_reply', questionId);
274
275
  return {
275
276
  behavior: 'allow',
276
277
  updatedInput: { ...input, answers: reply.answers },
@@ -283,16 +284,16 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
283
284
  if (toolName === 'ExitPlanMode') {
284
285
  try {
285
286
  const planId = randomUUID();
286
- await client.sendMessage(conversationId, 'Plan ready for review.', {
287
+ const planApproval = buildPlanApprovalRequest(planId);
288
+ await client.sendMessage(conversationId, planApproval.text, {
287
289
  metadata: {
288
- type: 'plan_approval',
289
- planId,
290
+ ...planApproval.metadata,
290
291
  turnSemantics: 'control',
291
292
  replyBehavior: 'suppress_auto_reply',
292
293
  },
293
294
  });
294
295
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
295
- const reply = await waitForReply();
296
+ const reply = await waitForReply('plan_approval_reply', planId);
296
297
  if (reply.decision === 'approve') {
297
298
  return { behavior: 'allow' };
298
299
  }
@@ -759,12 +760,41 @@ export async function main() {
759
760
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
760
761
  availableExecutionModes: hostAvailableExecutionModes,
761
762
  };
763
+ const conversationCache = new Map();
764
+ const knownConversationIds = new Set();
765
+ let lastKnownConversationRefreshAt = 0;
762
766
  const publishRuntimeHeartbeat = async () => {
763
767
  if (!streamConnected)
764
768
  return;
769
+ await refreshKnownConversationIds().catch((error) => {
770
+ console.error('[canon-host] Failed to refresh known conversations:', error);
771
+ });
765
772
  await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
766
773
  console.error('[canon-host] Failed to publish agent runtime:', error);
767
774
  });
775
+ await publishHostSessionSnapshots({
776
+ conversationIds: Array.from(knownConversationIds),
777
+ agentId,
778
+ clientType: 'claude-code',
779
+ runtime: runtimeDescriptor,
780
+ workspaceOptions,
781
+ defaultCwd: workingDir,
782
+ liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
783
+ const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
784
+ return [
785
+ session.conversationId,
786
+ {
787
+ ...(session.state.model ? { model: session.state.model } : {}),
788
+ ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
789
+ ...(workspaceId ? { workspaceId } : {}),
790
+ executionMode: session.environment.mode,
791
+ executionBranch: session.environment.branch ?? null,
792
+ },
793
+ ];
794
+ })),
795
+ }).catch((error) => {
796
+ console.error('[canon-host] Failed to publish session snapshots:', error);
797
+ });
768
798
  };
769
799
  // โ”€โ”€ Load conversations (informational โ€” sessions created on demand) โ”€โ”€
770
800
  try {
@@ -790,6 +820,11 @@ export async function main() {
790
820
  }
791
821
  try {
792
822
  const convos = await client.getConversations();
823
+ lastKnownConversationRefreshAt = Date.now();
824
+ for (const conversation of convos) {
825
+ knownConversationIds.add(conversation.id);
826
+ conversationCache.set(conversation.id, conversation);
827
+ }
793
828
  console.error(`[canon-host] ${convos.length} conversations`);
794
829
  }
795
830
  catch {
@@ -800,11 +835,25 @@ export async function main() {
800
835
  const pendingSessionCreations = new Map();
801
836
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
802
837
  const savedSessionIds = new Map();
803
- const conversationCache = new Map();
804
838
  const { getConversationMeta } = createConversationMetadataLoader({
805
839
  client,
806
840
  conversationCache,
807
841
  });
842
+ function resolveWorkspaceIdForBaseCwd(baseCwd) {
843
+ return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
844
+ }
845
+ async function refreshKnownConversationIds(force = false) {
846
+ if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
847
+ return;
848
+ }
849
+ const conversations = await client.getConversations();
850
+ knownConversationIds.clear();
851
+ for (const conversation of conversations) {
852
+ knownConversationIds.add(conversation.id);
853
+ conversationCache.set(conversation.id, conversation);
854
+ }
855
+ lastKnownConversationRefreshAt = Date.now();
856
+ }
808
857
  async function loadHydratedInboundContext(input) {
809
858
  const [conversation, page] = await Promise.all([
810
859
  getConversationMeta(input.conversationId),
@@ -865,6 +914,7 @@ export async function main() {
865
914
  }
866
915
  }
867
916
  async function getOrCreateSession(conversationId) {
917
+ knownConversationIds.add(conversationId);
868
918
  const existing = sessions.get(conversationId);
869
919
  if (existing && !existing.closed) {
870
920
  existing.lastActivity = Date.now();
@@ -937,15 +987,33 @@ export async function main() {
937
987
  if (m.senderId === agentId)
938
988
  return;
939
989
  const convoId = payload.conversationId;
990
+ knownConversationIds.add(convoId);
940
991
  // Check for metadata replies (question answers, plan approvals)
941
992
  if (m.metadata?.type === 'question_reply' || m.metadata?.type === 'plan_approval_reply') {
942
993
  const existingSession = sessions.get(convoId);
994
+ const pendingReply = existingSession?.pendingReply;
995
+ const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
996
+ const replyRequestId = m.metadata.type === 'question_reply'
997
+ ? m.metadata.questionId
998
+ : m.metadata.planId;
999
+ if (!pendingReply) {
1000
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
1001
+ return;
1002
+ }
1003
+ if (!isOwnerReply) {
1004
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
1005
+ return;
1006
+ }
1007
+ if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
1008
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
1009
+ return;
1010
+ }
943
1011
  if (existingSession?.pendingReply) {
944
1012
  const { resolve } = existingSession.pendingReply;
945
1013
  existingSession.pendingReply = null;
946
1014
  existingSession.lastActivity = Date.now();
947
1015
  resolve(m.metadata);
948
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type})`);
1016
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
949
1017
  return;
950
1018
  }
951
1019
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
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": "^0.8.2",
35
- "@canonmsg/core": "^0.7.4",
34
+ "@canonmsg/agent-sdk": "^0.8.3",
35
+ "@canonmsg/core": "^0.7.5",
36
36
  "@modelcontextprotocol/sdk": "^1.12.0"
37
37
  },
38
38
  "engines": {