@canonmsg/claude-code-plugin 0.15.0 → 0.15.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.
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, materializeReplyContextMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
29
- import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
29
+ import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
30
30
  import { decideAutoReply, } from './inbound-policy.js';
31
31
  import { runCli } from './cli-entry.js';
32
32
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
@@ -87,21 +87,6 @@ let runtimeMetadata = {
87
87
  let runtimeMetadataRefresh = null;
88
88
  function buildClaudeRuntimeDescriptor(input) {
89
89
  const commands = [
90
- {
91
- id: 'plan',
92
- label: 'Plan first',
93
- description: 'Switch Claude into Plan mode. Text after /plan is kept as a ready prompt while the mode applies.',
94
- aliases: ['plan'],
95
- category: 'plan',
96
- placements: ['composer_slash', 'command_palette'],
97
- availability: ['always'],
98
- trailingTextBehavior: 'send_as_prompt',
99
- dispatch: {
100
- kind: 'control',
101
- controlId: 'permissionMode',
102
- value: 'plan',
103
- },
104
- },
105
90
  {
106
91
  id: 'runtime-status',
107
92
  label: 'Runtime status',
@@ -190,6 +175,25 @@ function buildClaudeRuntimeDescriptor(input) {
190
175
  permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
191
176
  defaultPermissionMode: 'default',
192
177
  streamingTextMode: 'delta',
178
+ turnModes: [
179
+ {
180
+ id: 'normal',
181
+ label: 'Normal',
182
+ description: 'Let Claude answer or act normally.',
183
+ scope: 'session',
184
+ default: true,
185
+ activation: { kind: 'control', controlId: 'permissionMode', value: 'default' },
186
+ },
187
+ {
188
+ id: 'plan',
189
+ label: 'Plan',
190
+ description: 'Switch Claude Code into Plan mode.',
191
+ scope: 'session',
192
+ ownerOnly: true,
193
+ aliases: ['plan'],
194
+ activation: { kind: 'control', controlId: 'permissionMode', value: 'plan' },
195
+ },
196
+ ],
193
197
  admissionActions: HOST_ADMISSION_ACTIONS_DISABLED,
194
198
  presentation: input.presentation,
195
199
  commands,
@@ -380,6 +384,71 @@ async function refreshClaudeRuntimeMetadata(input) {
380
384
  });
381
385
  return runtimeMetadataRefresh;
382
386
  }
387
+ function sleep(ms) {
388
+ return new Promise((resolve) => setTimeout(resolve, ms));
389
+ }
390
+ function mapClaudeQuestions(value) {
391
+ if (!Array.isArray(value))
392
+ return undefined;
393
+ const questions = value.slice(0, 12).flatMap((entry) => {
394
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
395
+ return [];
396
+ const record = entry;
397
+ const id = typeof record.id === 'string' && record.id.trim()
398
+ ? record.id.trim().slice(0, 120)
399
+ : typeof record.question === 'string' && record.question.trim()
400
+ ? record.question.trim().slice(0, 80).replace(/[^A-Za-z0-9_.:-]+/g, '-')
401
+ : null;
402
+ const question = typeof record.question === 'string' && record.question.trim()
403
+ ? record.question.trim().slice(0, 1000)
404
+ : null;
405
+ if (!id || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id) || !question)
406
+ return [];
407
+ const header = typeof record.header === 'string' && record.header.trim()
408
+ ? record.header.trim().slice(0, 120)
409
+ : undefined;
410
+ const rawOptions = Array.isArray(record.options) ? record.options : [];
411
+ const choices = rawOptions.slice(0, 12).flatMap((option) => {
412
+ if (!option || typeof option !== 'object' || Array.isArray(option))
413
+ return [];
414
+ const optionRecord = option;
415
+ const label = typeof optionRecord.label === 'string' && optionRecord.label.trim()
416
+ ? optionRecord.label.trim().slice(0, 120)
417
+ : typeof optionRecord.value === 'string' && optionRecord.value.trim()
418
+ ? optionRecord.value.trim().slice(0, 120)
419
+ : null;
420
+ if (!label)
421
+ return [];
422
+ const optionValue = typeof optionRecord.value === 'string' && optionRecord.value.trim()
423
+ ? optionRecord.value.trim().slice(0, 120)
424
+ : label;
425
+ return [{
426
+ label,
427
+ value: optionValue,
428
+ ...(typeof optionRecord.description === 'string' && optionRecord.description.trim()
429
+ ? { description: optionRecord.description.trim().slice(0, 240) }
430
+ : {}),
431
+ }];
432
+ });
433
+ return [{
434
+ id,
435
+ question,
436
+ ...(header ? { header } : {}),
437
+ ...(choices.length ? { choices } : {}),
438
+ ...(record.isOther === true || record.allowOther === true ? { allowOther: true } : {}),
439
+ ...(record.isSecret === true ? { isSecret: true } : {}),
440
+ ...(record.multiSelect === true ? { multiSelect: true } : {}),
441
+ }];
442
+ });
443
+ return questions.length ? questions : undefined;
444
+ }
445
+ function flattenRuntimeInputAnswers(answers) {
446
+ const flattened = {};
447
+ for (const [questionId, answer] of Object.entries(answers ?? {})) {
448
+ flattened[questionId] = answer.answers.join(', ');
449
+ }
450
+ return flattened;
451
+ }
383
452
  function mapClaudeMcpStatus(status) {
384
453
  switch (status) {
385
454
  case 'connected':
@@ -692,6 +761,30 @@ function createSession(conversationId, environment, agentId, client, typingSigna
692
761
  };
693
762
  });
694
763
  }
764
+ async function waitForRuntimeInputResponse(input) {
765
+ session.lastActivity = Date.now();
766
+ while (Date.now() < input.expiresAt) {
767
+ const response = await client.consumeRuntimeInputResponse({
768
+ conversationId,
769
+ inputId: input.inputId,
770
+ }).catch(() => null);
771
+ if (response?.status === 'submitted') {
772
+ return { status: 'submitted', answers: response.answers, value: response.value };
773
+ }
774
+ if (response?.status === 'cancelled' || response?.status === 'timeout') {
775
+ return { status: response.status };
776
+ }
777
+ await sleep(1_000);
778
+ }
779
+ const response = await client.consumeRuntimeInputResponse({
780
+ conversationId,
781
+ inputId: input.inputId,
782
+ }).catch(() => null);
783
+ if (response?.status === 'submitted') {
784
+ return { status: 'submitted', answers: response.answers, value: response.value };
785
+ }
786
+ return { status: 'timeout' };
787
+ }
695
788
  const claudeCliPath = resolveClaudeCliPath();
696
789
  const queryOptions = {
697
790
  systemPrompt: { type: 'preset', preset: 'claude_code' },
@@ -715,24 +808,43 @@ function createSession(conversationId, environment, agentId, client, typingSigna
715
808
  if (toolName === 'AskUserQuestion') {
716
809
  try {
717
810
  const questionId = randomUUID();
718
- const questions = input.questions ?? [];
719
- const questionCard = buildQuestionRequest(questionId, questions, cardResponseUser);
720
- await sendMessageWithRetry(client, conversationId, questionCard.text, {
721
- messageId: `claude-question-${questionId}`,
722
- ...(session.activeSelfContextId
723
- ? { selfContextId: session.activeSelfContextId }
724
- : {}),
725
- metadata: {
726
- ...questionCard.metadata,
727
- turnSemantics: 'control',
728
- replyBehavior: 'suppress_auto_reply',
811
+ const questions = mapClaudeQuestions(input.questions);
812
+ const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
813
+ await client.createRuntimeInputRequest({
814
+ conversationId,
815
+ inputId: questionId,
816
+ kind: 'clarify',
817
+ expiresAt,
818
+ title: 'Claude needs input',
819
+ prompt: questions?.length
820
+ ? 'Claude needs your input to continue.'
821
+ : 'Claude needs input to continue.',
822
+ ...(questions ? { questions } : {}),
823
+ sensitive: Boolean(questions?.some((question) => question.isSecret)),
824
+ ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
825
+ native: {
826
+ runtime: 'claude-code',
827
+ method: 'AskUserQuestion',
828
+ requestId: questionId,
829
+ turnId: session.currentTurnId ?? undefined,
729
830
  },
831
+ turnId: session.currentTurnId ?? undefined,
730
832
  });
731
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Question sent (${questionId.slice(0, 8)})`);
732
- const reply = await waitForReply('question_reply', questionId);
833
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
834
+ const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
835
+ if (reply.status !== 'submitted') {
836
+ return { behavior: 'deny', message: 'No reply received (timed out)' };
837
+ }
733
838
  return {
734
839
  behavior: 'allow',
735
- updatedInput: { ...input, answers: reply.answers },
840
+ updatedInput: {
841
+ ...input,
842
+ answers: reply.answers
843
+ ? flattenRuntimeInputAnswers(reply.answers)
844
+ : reply.value
845
+ ? { answer: reply.value }
846
+ : {},
847
+ },
736
848
  };
737
849
  }
738
850
  catch {
@@ -2184,6 +2296,10 @@ export async function main() {
2184
2296
  }
2185
2297
  const sender = m.senderName || m.senderId;
2186
2298
  const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
2299
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
2300
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
2301
+ return;
2302
+ }
2187
2303
  void (async () => {
2188
2304
  let materialized = [];
2189
2305
  if (m.id) {
@@ -2218,7 +2334,12 @@ export async function main() {
2218
2334
  });
2219
2335
  const replyContext = replyMedia.replyContext;
2220
2336
  const participantContext = hydrated.participantContext;
2221
- const autoReply = decideAutoReply(participantContext, behavior);
2337
+ const autoReply = payload.turnDispatch?.kind === 'run_turn'
2338
+ ? {
2339
+ allow: true,
2340
+ reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
2341
+ }
2342
+ : decideAutoReply(participantContext, behavior);
2222
2343
  if (!autoReply.allow) {
2223
2344
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2224
2345
  return;
@@ -1,4 +1,4 @@
1
- import type { SendContextualMessageOptions } from '@canonmsg/core';
1
+ import type { DirectSessionSelection, SendContextualMessageOptions } from '@canonmsg/core';
2
2
  export type McpParseResult<T> = {
3
3
  ok: true;
4
4
  value: T;
@@ -32,6 +32,7 @@ export interface SendContextualMessageArgs {
32
32
  targetUserId?: string;
33
33
  text: string;
34
34
  selfContext: SendContextualMessageOptions['selfContext'];
35
+ sessionSelection?: DirectSessionSelection;
35
36
  messageOptions?: SendContextualMessageOptions['messageOptions'];
36
37
  }
37
38
  export declare function parseSendContextualMessageArgs(value: unknown): McpParseResult<SendContextualMessageArgs>;
package/dist/mcp-args.js CHANGED
@@ -87,6 +87,28 @@ export function parseSendMessageArgs(value) {
87
87
  },
88
88
  };
89
89
  }
90
+ function parseSessionSelection(value) {
91
+ if (value == null)
92
+ return { ok: true, value: undefined };
93
+ const raw = typeof value === 'string'
94
+ ? value
95
+ : isRecord(value) && typeof value.mode === 'string'
96
+ ? value.mode
97
+ : null;
98
+ const mode = raw?.trim().toLowerCase().replace(/-/g, '_');
99
+ if (!mode)
100
+ return { ok: false, error: 'Invalid session_selection' };
101
+ if (mode === 'new' || mode === 'fresh' || mode === 'start_new') {
102
+ return { ok: true, value: { mode: 'new' } };
103
+ }
104
+ if (mode === 'continue_latest' || mode === 'latest' || mode === 'continue') {
105
+ return { ok: true, value: { mode: 'continue_latest' } };
106
+ }
107
+ if (mode === 'continue_or_create' || mode === 'auto' || mode === 'reuse_or_create') {
108
+ return { ok: true, value: { mode: 'continue_or_create' } };
109
+ }
110
+ return { ok: false, error: 'session_selection must be new, continue_latest, or continue_or_create' };
111
+ }
90
112
  export function parseSendContextualMessageArgs(value) {
91
113
  if (!isRecord(value))
92
114
  return { ok: false, error: 'Arguments must be an object' };
@@ -111,6 +133,9 @@ export function parseSendContextualMessageArgs(value) {
111
133
  if (type !== 'cross_session') {
112
134
  return { ok: false, error: 'self_context.type must be cross_session' };
113
135
  }
136
+ const sessionSelection = parseSessionSelection(value.session_selection);
137
+ if (!sessionSelection.ok)
138
+ return sessionSelection;
114
139
  const messageOptions = isRecord(value.message_options)
115
140
  ? value.message_options
116
141
  : undefined;
@@ -125,6 +150,7 @@ export function parseSendContextualMessageArgs(value) {
125
150
  type: 'cross_session',
126
151
  context: context.value,
127
152
  },
153
+ ...(sessionSelection.value ? { sessionSelection: sessionSelection.value } : {}),
128
154
  ...(messageOptions ? { messageOptions } : {}),
129
155
  },
130
156
  };
package/dist/server.js CHANGED
@@ -163,6 +163,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
163
163
  type: 'object',
164
164
  description: 'Required { type: "cross_session", context: "why I am saying this here" }',
165
165
  },
166
+ session_selection: {
167
+ type: 'string',
168
+ enum: ['new', 'continue_latest', 'continue_or_create'],
169
+ description: 'Optional direct-agent session behavior when target_user_id is used',
170
+ },
166
171
  message_options: {
167
172
  type: 'object',
168
173
  description: 'Optional Canon send options for the contextual outbound message',
@@ -321,7 +326,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
321
326
  const parsed = parseSendContextualMessageArgs(args);
322
327
  if (!parsed.ok)
323
328
  return toolArgumentError(parsed.error);
324
- const { sourceConversationId, targetConversationId, targetUserId, text, selfContext, messageOptions, } = parsed.value;
329
+ const { sourceConversationId, targetConversationId, targetUserId, text, selfContext, sessionSelection, messageOptions, } = parsed.value;
325
330
  const contextualMessageOptions = messageOptions;
326
331
  const result = await client.sendContextualMessage({
327
332
  sourceConversationId,
@@ -329,6 +334,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
329
334
  ...(targetUserId ? { targetUserId } : {}),
330
335
  text,
331
336
  selfContext,
337
+ ...(sessionSelection ? { sessionSelection } : {}),
332
338
  ...(contextualMessageOptions
333
339
  ? {
334
340
  messageOptions: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.15.0",
3
+ "version": "0.15.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,8 +31,8 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.141",
34
- "@canonmsg/agent-sdk": "^3.0.0",
35
- "@canonmsg/core": "^2.0.0",
34
+ "@canonmsg/agent-sdk": "^3.1.1",
35
+ "@canonmsg/core": "^2.1.1",
36
36
  "@modelcontextprotocol/sdk": "^1.29.0"
37
37
  },
38
38
  "engines": {