@canonmsg/claude-code-plugin 0.14.18 → 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 +165 -57
  2. package/package.json +4 -4
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, 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, buildCanonHostPrompt, buildCanonTurnEnvelopeV1, 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, renderCanonTurnEnvelopePrompt, resolveCanonHostPromptMode, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
30
- import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
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
+ import { decideAutoReply, } from './inbound-policy.js';
31
31
  import { runCli } from './cli-entry.js';
32
32
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
33
33
  import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.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':
@@ -535,35 +604,24 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
535
604
  supportsRequiresAction: true,
536
605
  supportsNonFinalPermanentMessages: false,
537
606
  };
538
- const HOST_PROMPT_MODE = resolveCanonHostPromptMode(process.env.CANON_HOST_PROMPT_MODE);
539
607
  function controlPollDelayMs(hasActiveWork) {
540
608
  const base = hasActiveWork ? CONTROL_POLL_MS : IDLE_CONTROL_POLL_MS;
541
609
  return base + Math.floor(Math.random() * CONTROL_POLL_JITTER_MS);
542
610
  }
543
611
  function buildCanonPrompt(input) {
544
- const { session, ...promptInput } = input;
545
- const sessionContextLines = session ? buildClaudeSessionContextLines(session) : undefined;
546
- if (HOST_PROMPT_MODE === 'envelope') {
547
- return renderCanonTurnEnvelopePrompt(buildCanonTurnEnvelopeV1({
548
- content: input.content,
549
- conversationId: input.conversationId,
550
- participantContext: input.participantContext,
551
- behavior: input.behavior,
552
- selfContexts: input.selfContexts,
553
- activeSelfContextId: input.activeSelfContextId,
554
- provenance: input.provenance,
555
- replyContext: input.replyContext,
556
- sessionContextLines,
557
- message: input.message,
558
- hydratedFromPage: input.hydratedFromPage,
559
- }));
560
- }
561
- return buildCanonHostPrompt({
562
- hostLabel: 'Claude Code',
563
- buildInboundContextLines,
612
+ const sessionContextLines = input.session ? buildClaudeSessionContextLines(input.session) : undefined;
613
+ return renderCanonTurnBriefPrompt(buildCanonTurnContextV2({
614
+ content: input.content,
615
+ conversationId: input.conversationId,
616
+ participantContext: input.participantContext,
617
+ behavior: input.behavior,
618
+ selfContexts: input.selfContexts,
619
+ activeSelfContextId: input.activeSelfContextId,
620
+ provenance: input.provenance,
621
+ replyContext: input.replyContext,
564
622
  sessionContextLines,
565
- ...promptInput,
566
- });
623
+ message: input.message,
624
+ }));
567
625
  }
568
626
  function optionLabel(value, options) {
569
627
  if (!value)
@@ -703,6 +761,30 @@ function createSession(conversationId, environment, agentId, client, typingSigna
703
761
  };
704
762
  });
705
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
+ }
706
788
  const claudeCliPath = resolveClaudeCliPath();
707
789
  const queryOptions = {
708
790
  systemPrompt: { type: 'preset', preset: 'claude_code' },
@@ -726,24 +808,43 @@ function createSession(conversationId, environment, agentId, client, typingSigna
726
808
  if (toolName === 'AskUserQuestion') {
727
809
  try {
728
810
  const questionId = randomUUID();
729
- const questions = input.questions ?? [];
730
- const questionCard = buildQuestionRequest(questionId, questions, cardResponseUser);
731
- await sendMessageWithRetry(client, conversationId, questionCard.text, {
732
- messageId: `claude-question-${questionId}`,
733
- ...(session.activeSelfContextId
734
- ? { selfContextId: session.activeSelfContextId }
735
- : {}),
736
- metadata: {
737
- ...questionCard.metadata,
738
- turnSemantics: 'control',
739
- 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,
740
830
  },
831
+ turnId: session.currentTurnId ?? undefined,
741
832
  });
742
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Question sent (${questionId.slice(0, 8)})`);
743
- 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
+ }
744
838
  return {
745
839
  behavior: 'allow',
746
- 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
+ },
747
848
  };
748
849
  }
749
850
  catch {
@@ -2088,7 +2189,6 @@ export async function main() {
2088
2189
  provenance: hydrated.provenance,
2089
2190
  replyContext,
2090
2191
  message: m,
2091
- hydratedFromPage: hydrated.hydratedFromPage,
2092
2192
  session,
2093
2193
  });
2094
2194
  const messageContent = await buildCanonUserContent({
@@ -2196,6 +2296,10 @@ export async function main() {
2196
2296
  }
2197
2297
  const sender = m.senderName || m.senderId;
2198
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
+ }
2199
2303
  void (async () => {
2200
2304
  let materialized = [];
2201
2305
  if (m.id) {
@@ -2230,7 +2334,12 @@ export async function main() {
2230
2334
  });
2231
2335
  const replyContext = replyMedia.replyContext;
2232
2336
  const participantContext = hydrated.participantContext;
2233
- 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);
2234
2343
  if (!autoReply.allow) {
2235
2344
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
2236
2345
  return;
@@ -2270,7 +2379,6 @@ export async function main() {
2270
2379
  provenance: hydrated.provenance,
2271
2380
  replyContext,
2272
2381
  message: m,
2273
- hydratedFromPage: hydrated.hydratedFromPage,
2274
2382
  session,
2275
2383
  });
2276
2384
  const messageContent = await buildCanonUserContent({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.14.18",
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": "^2.4.0",
35
- "@canonmsg/core": "^1.7.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": {
@@ -58,7 +58,7 @@
58
58
  "devDependencies": {
59
59
  "@types/node": "^22.0.0",
60
60
  "typescript": "~5.7.0",
61
- "vitest": "^3.0.0"
61
+ "vitest": "^4.1.8"
62
62
  },
63
63
  "license": "MIT"
64
64
  }