@canonmsg/claude-code-plugin 0.9.4 โ†’ 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 { 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
  }
@@ -755,15 +756,45 @@ export async function main() {
755
756
  let runtimeDescriptor = {
756
757
  defaultWorkspaceId: workspaceOptions[0]?.id,
757
758
  defaultPermissionMode: 'default',
759
+ availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
758
760
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
759
761
  availableExecutionModes: hostAvailableExecutionModes,
760
762
  };
763
+ const conversationCache = new Map();
764
+ const knownConversationIds = new Set();
765
+ let lastKnownConversationRefreshAt = 0;
761
766
  const publishRuntimeHeartbeat = async () => {
762
767
  if (!streamConnected)
763
768
  return;
769
+ await refreshKnownConversationIds().catch((error) => {
770
+ console.error('[canon-host] Failed to refresh known conversations:', error);
771
+ });
764
772
  await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
765
773
  console.error('[canon-host] Failed to publish agent runtime:', error);
766
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
+ });
767
798
  };
768
799
  // โ”€โ”€ Load conversations (informational โ€” sessions created on demand) โ”€โ”€
769
800
  try {
@@ -772,6 +803,7 @@ export async function main() {
772
803
  defaultWorkspaceId: workspaceOptions[0]?.id,
773
804
  defaultModel: runtimeModels[0]?.value,
774
805
  defaultPermissionMode: 'default',
806
+ availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
775
807
  availableModels: runtimeModels,
776
808
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
777
809
  availableExecutionModes: hostAvailableExecutionModes,
@@ -781,12 +813,18 @@ export async function main() {
781
813
  runtimeDescriptor = {
782
814
  defaultWorkspaceId: workspaceOptions[0]?.id,
783
815
  defaultPermissionMode: 'default',
816
+ availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
784
817
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
785
818
  availableExecutionModes: hostAvailableExecutionModes,
786
819
  };
787
820
  }
788
821
  try {
789
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
+ }
790
828
  console.error(`[canon-host] ${convos.length} conversations`);
791
829
  }
792
830
  catch {
@@ -797,11 +835,25 @@ export async function main() {
797
835
  const pendingSessionCreations = new Map();
798
836
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
799
837
  const savedSessionIds = new Map();
800
- const conversationCache = new Map();
801
838
  const { getConversationMeta } = createConversationMetadataLoader({
802
839
  client,
803
840
  conversationCache,
804
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
+ }
805
857
  async function loadHydratedInboundContext(input) {
806
858
  const [conversation, page] = await Promise.all([
807
859
  getConversationMeta(input.conversationId),
@@ -862,6 +914,7 @@ export async function main() {
862
914
  }
863
915
  }
864
916
  async function getOrCreateSession(conversationId) {
917
+ knownConversationIds.add(conversationId);
865
918
  const existing = sessions.get(conversationId);
866
919
  if (existing && !existing.closed) {
867
920
  existing.lastActivity = Date.now();
@@ -934,15 +987,33 @@ export async function main() {
934
987
  if (m.senderId === agentId)
935
988
  return;
936
989
  const convoId = payload.conversationId;
990
+ knownConversationIds.add(convoId);
937
991
  // Check for metadata replies (question answers, plan approvals)
938
992
  if (m.metadata?.type === 'question_reply' || m.metadata?.type === 'plan_approval_reply') {
939
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
+ }
940
1011
  if (existingSession?.pendingReply) {
941
1012
  const { resolve } = existingSession.pendingReply;
942
1013
  existingSession.pendingReply = null;
943
1014
  existingSession.lastActivity = Date.now();
944
1015
  resolve(m.metadata);
945
- 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)})`);
946
1017
  return;
947
1018
  }
948
1019
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.9.4",
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",
@@ -23,15 +23,16 @@
23
23
  ".claude-plugin"
24
24
  ],
25
25
  "scripts": {
26
- "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
27
- "dev": "tsc --watch",
26
+ "prepare:workspace-deps": "npm --prefix ../core run build && npm --prefix ../agent-sdk run build",
27
+ "build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
28
+ "dev": "npm run prepare:workspace-deps && tsc --watch",
28
29
  "prepack": "npm run build",
29
30
  "test": "vitest run"
30
31
  },
31
32
  "dependencies": {
32
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
33
- "@canonmsg/agent-sdk": "^0.8.2",
34
- "@canonmsg/core": "^0.7.3",
34
+ "@canonmsg/agent-sdk": "^0.8.3",
35
+ "@canonmsg/core": "^0.7.5",
35
36
  "@modelcontextprotocol/sdk": "^1.12.0"
36
37
  },
37
38
  "engines": {