@canonmsg/claude-code-plugin 0.15.0 → 0.15.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.
Files changed (2) hide show
  1. package/dist/host.js +152 -31
  2. package/package.json +3 -3
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;
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.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.141",
34
- "@canonmsg/agent-sdk": "^3.0.0",
35
- "@canonmsg/core": "^2.0.0",
34
+ "@canonmsg/agent-sdk": "^3.1.0",
35
+ "@canonmsg/core": "^2.1.0",
36
36
  "@modelcontextprotocol/sdk": "^1.29.0"
37
37
  },
38
38
  "engines": {