@canonmsg/claude-code-plugin 0.25.2 → 0.25.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "Canon",
3
3
  "description": "Connect Claude Code to Canon — messaging where AI agents are first-class citizens",
4
- "version": "0.25.2",
4
+ "version": "0.25.3",
5
5
  "channels": [
6
6
  {
7
7
  "server": "canon-channel",
package/dist/host.js CHANGED
@@ -33,8 +33,8 @@ import { decideAutoReply, } from './inbound-policy.js';
33
33
  import { runCli } from './cli-entry.js';
34
34
  import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
35
35
  import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
36
- import { applyClaudeSessionControl, buildClaudeFinalMessageId, claudeModelInfoToOption, confirmClaudeInterrupt, createClaudeInputEnvelope, formatClaudeControlError, isClaudeCustomModelOption, mergeClaudeDiscoveredModelOptions, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
37
- import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, } from './user-dialog.js';
36
+ import { applyClaudeSessionControl, buildClaudeFinalMessageId, claudeModelInfoToOption, confirmClaudeInterrupt, createClaudeInputEnvelope, formatClaudeControlError, isClaudeCustomModelOption, mergeClaudeDiscoveredModelOptions, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
37
+ import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
38
38
  import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
39
39
  function parseRuntimeVisibilityPreset(value) {
40
40
  return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
@@ -738,7 +738,6 @@ async function buildCanonUserContent(input) {
738
738
  // ── Session factory ─────────────────────────────────────────────────
739
739
  function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
740
740
  const { cwd } = environment;
741
- const cardResponseUserId = config.ownerId ?? undefined;
742
741
  // Per-session input stream with message queue
743
742
  let resolveInput = null;
744
743
  let inputDone = false;
@@ -796,23 +795,17 @@ function createSession(conversationId, environment, agentId, client, typingSigna
796
795
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
797
796
  let session;
798
797
  const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
799
- const activeUserDialogs = new Map();
800
- const completedUserDialogs = new Map();
798
+ const coordinateUserDialog = createClaudeUserDialogCoordinator();
801
799
  async function requestClaudeUserDialog(dialog, nativeRequestId, signal) {
802
800
  const questionId = /^[A-Za-z0-9_.:-]{1,160}$/.test(nativeRequestId)
803
801
  ? nativeRequestId
804
802
  : randomUUID();
805
- const completed = completedUserDialogs.get(questionId);
806
- if (completed)
807
- return completed;
808
- const existing = activeUserDialogs.get(questionId);
809
- if (existing)
810
- return existing;
811
- const responseUserId = session.activeInput?.requestingUserId ?? undefined;
803
+ const responseRouting = resolveClaudeTurnResponseRouting(session.activeInput, config.ownerId, dialog.questions.some((question) => question.isSecret));
804
+ const responseUserId = responseRouting.responseUserId;
812
805
  if (!responseUserId) {
813
806
  return buildClaudeAskUserPermissionDenied('No eligible human participant can answer this question.');
814
807
  }
815
- const run = (async () => {
808
+ return coordinateUserDialog(questionId, async () => {
816
809
  try {
817
810
  const reply = await config.runtimeInputManager.request('input', conversationId, {
818
811
  kind: 'clarify',
@@ -846,24 +839,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
846
839
  catch {
847
840
  return buildClaudeAskUserPermissionDenied(signal.aborted ? 'The question was interrupted.' : 'No reply received (timed out)');
848
841
  }
849
- })();
850
- activeUserDialogs.set(questionId, run);
851
- try {
852
- const result = await run;
853
- completedUserDialogs.set(questionId, result);
854
- while (completedUserDialogs.size > 64) {
855
- const oldest = completedUserDialogs.keys().next().value;
856
- if (oldest === undefined)
857
- break;
858
- completedUserDialogs.delete(oldest);
859
- }
860
- return result;
861
- }
862
- finally {
863
- activeUserDialogs.delete(questionId);
864
- }
842
+ });
865
843
  }
866
- function waitForReply(type, requestId) {
844
+ function waitForReply(type, requestId, responseUserId) {
867
845
  // Keep session alive while waiting for user input
868
846
  session.lastActivity = Date.now();
869
847
  return new Promise((resolve, reject) => {
@@ -874,6 +852,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
874
852
  session.pendingReply = {
875
853
  type,
876
854
  requestId,
855
+ responseUserId,
877
856
  resolve: (metadata) => {
878
857
  clearTimeout(timer);
879
858
  resolve(metadata);
@@ -916,7 +895,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
916
895
  const dialog = parseClaudeAskUserDialog(request);
917
896
  if (!dialog)
918
897
  return { behavior: 'cancelled' };
919
- const nativeRequestId = dialog.requestId ?? request.toolUseID ?? randomUUID();
898
+ const nativeRequestId = resolveClaudeUserDialogRequestId(request, dialog) ?? randomUUID();
920
899
  const result = await requestClaudeUserDialog(dialog, nativeRequestId, signal);
921
900
  return signal.aborted ? { behavior: 'cancelled' } : { behavior: 'completed', result };
922
901
  },
@@ -927,7 +906,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
927
906
  return buildClaudeAskUserPermissionDenied('Claude did not provide a valid question.');
928
907
  return requestClaudeUserDialog(dialog, options.toolUseID, options.signal);
929
908
  }
909
+ const turnResponseRouting = resolveClaudeTurnResponseRouting(session.activeInput, config.ownerId);
930
910
  if (toolName === 'ExitPlanMode') {
911
+ if (!turnResponseRouting.responseUserId) {
912
+ return {
913
+ behavior: 'deny',
914
+ message: 'No eligible human participant can review this plan.',
915
+ };
916
+ }
931
917
  try {
932
918
  const planId = randomUUID();
933
919
  const allowedPrompts = Array.isArray(input.allowedPrompts)
@@ -963,22 +949,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
963
949
  title: 'Plan ready for review.',
964
950
  summary: 'Claude has prepared a plan and is asking permission to continue.',
965
951
  ...(planBody ? { body: planBody } : {}),
966
- ...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
952
+ responseUserId: turnResponseRouting.responseUserId,
967
953
  ...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
968
954
  ...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
969
955
  expiresAt: Date.now() + REPLY_TIMEOUT_MS,
970
956
  });
971
957
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
972
- const reply = await waitForReply('plan_approval_reply', planId);
958
+ const reply = await waitForReply('plan_approval_reply', planId, turnResponseRouting.responseUserId);
973
959
  if (reply.decision === 'approve') {
974
960
  // Honor opt-in selective grants: when the card sent an explicit
975
961
  // granted subset, pre-approve those tools as session rules so they
976
962
  // do not re-prompt. Owner-turn only; absent grants preserve the
977
963
  // legacy behavior (each tool still prompts individually).
978
- const isOwnerTurn = session.activeInput?.kind === 'canon'
979
- ? session.activeInput.isOwnerTurn === true
980
- : true;
981
- if (isOwnerTurn && config.approvalManager && Array.isArray(reply.grantedPrompts)) {
964
+ if (turnResponseRouting.allowSessionRule
965
+ && config.approvalManager
966
+ && Array.isArray(reply.grantedPrompts)) {
982
967
  // Only pre-approve tools the plan actually offered, and bound the
983
968
  // count/length to match parseSessionRule's caps — grantedPrompts
984
969
  // is untrusted wire metadata, not the validated approval path.
@@ -1038,9 +1023,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
1038
1023
  conversationId,
1039
1024
  runtimeId: config.runtimeId,
1040
1025
  turnId: session.currentTurnId,
1041
- isOwnerTurn: session.activeInput?.kind === 'canon'
1042
- ? session.activeInput.isOwnerTurn === true
1043
- : true,
1026
+ isOwnerTurn: turnResponseRouting.isOwnerTurn,
1027
+ responseUserId: turnResponseRouting.responseUserId,
1028
+ allowSessionRule: turnResponseRouting.allowSessionRule,
1044
1029
  approvalManager: config.approvalManager,
1045
1030
  canRequestApproval: config.canRequestApproval,
1046
1031
  allowedNonOwnerTools: allowedNonOwnerClaudeTools,
@@ -2578,14 +2563,13 @@ export async function main() {
2578
2563
  return false;
2579
2564
  const existingSession = sessions.get(conversationId);
2580
2565
  const pendingReply = existingSession?.pendingReply;
2581
- const isOwnerReply = message.isOwner ?? (ownerId != null && message.senderId === ownerId);
2582
2566
  const replyRequestId = message.metadata.planId;
2583
2567
  if (!pendingReply) {
2584
2568
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring stale ${message.metadata.type} with no pending request`);
2585
2569
  return true;
2586
2570
  }
2587
- if (!isOwnerReply) {
2588
- console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring ${message.metadata.type} from non-owner ${message.senderId}`);
2571
+ if (message.senderId !== pendingReply.responseUserId) {
2572
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring ${message.metadata.type} from unexpected responder ${message.senderId}`);
2589
2573
  return true;
2590
2574
  }
2591
2575
  if (pendingReply.type !== message.metadata.type || pendingReply.requestId !== replyRequestId) {
@@ -51,6 +51,11 @@ export declare function createClaudeInputEnvelope(input: {
51
51
  isOwnerTurn?: boolean;
52
52
  requestingUserId?: string | null;
53
53
  }): ClaudeInputEnvelope;
54
+ export declare function resolveClaudeTurnResponseRouting(turn: Pick<ClaudeInputEnvelope, 'kind' | 'isOwnerTurn' | 'requestingUserId'> | null | undefined, ownerId: string | null | undefined, ownerOnly?: boolean): {
55
+ isOwnerTurn: boolean;
56
+ responseUserId?: string;
57
+ allowSessionRule: boolean;
58
+ };
54
59
  export declare function buildClaudeFinalMessageId(input: {
55
60
  agentId: string;
56
61
  conversationId: string;
@@ -21,6 +21,17 @@ export function createClaudeInputEnvelope(input) {
21
21
  : `${input.kind}:${fallbackId}`,
22
22
  };
23
23
  }
24
+ export function resolveClaudeTurnResponseRouting(turn, ownerId, ownerOnly = false) {
25
+ const isOwnerTurn = turn?.kind === 'canon' ? turn.isOwnerTurn : true;
26
+ const responseUserId = ownerOnly
27
+ ? ownerId || undefined
28
+ : (turn?.requestingUserId ?? ownerId) || undefined;
29
+ return {
30
+ isOwnerTurn,
31
+ ...(responseUserId ? { responseUserId } : {}),
32
+ allowSessionRule: Boolean(responseUserId && ownerId && responseUserId === ownerId),
33
+ };
34
+ }
24
35
  export function buildClaudeFinalMessageId(input) {
25
36
  const digest = createHash('sha256')
26
37
  .update(`${input.agentId}\n${input.conversationId}\n${input.turnKey}`)
@@ -50,7 +50,10 @@ export async function collectMissedInboundMessages(input) {
50
50
  const resolvedCursor = cursorSearchComplete()
51
51
  ? findNewestCursor(collected)
52
52
  : null;
53
- const ascending = [...collected].sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
53
+ // Preserve the API's deterministic newest-first order. Timestamps are
54
+ // display data and may be equal or absent, so reversing wire order is the
55
+ // only lossless oldest-first replay sequence.
56
+ const ascending = [...collected].reverse();
54
57
  const completedSourceMessageIds = new Set(collected.flatMap((message) => {
55
58
  if (message.senderId !== input.agentId)
56
59
  return [];
@@ -24,12 +24,14 @@ export declare function decideClaudeToolPermissionForMode(input: {
24
24
  runtimeId?: string;
25
25
  turnId?: string | null;
26
26
  isOwnerTurn: boolean;
27
+ responseUserId?: string | null;
28
+ allowSessionRule?: boolean;
27
29
  approvalManager?: ApprovalManager | null;
28
30
  canRequestApproval?: () => Promise<boolean>;
29
31
  allowedNonOwnerTools?: Iterable<string>;
30
32
  /**
31
33
  * Lazy file-change preview for the approval card (Edit/Write-class tools).
32
- * Invoked only once the call is headed for an owner approval; best-effort —
34
+ * Invoked only once the call is headed for a responder approval; best-effort —
33
35
  * a throw or undefined simply omits the diff, never blocks the approval.
34
36
  */
35
37
  buildApprovalDiff?: () => Promise<CanonUnifiedDiff | undefined>;
@@ -1,6 +1,6 @@
1
1
  const NON_OWNER_DENY_MESSAGE = 'Only the agent owner can authorize runtime tool use in this chat.';
2
2
  const NATIVE_APPROVAL_UNAVAILABLE_MESSAGE = 'Runtime action approval is not available for this conversation.';
3
- const NATIVE_APPROVAL_DENIED_MESSAGE = 'The agent owner denied this action.';
3
+ const NATIVE_APPROVAL_DENIED_MESSAGE = 'The user denied this action.';
4
4
  export const CLAUDE_NATIVE_APPROVAL_PERMISSION_MODE = 'default';
5
5
  const SENSITIVE_CLAUDE_TOOLS = new Set([
6
6
  'agent',
@@ -174,6 +174,12 @@ export async function decideClaudeToolPermissionForMode(input) {
174
174
  message: NATIVE_APPROVAL_UNAVAILABLE_MESSAGE,
175
175
  };
176
176
  }
177
+ if (!input.isOwnerTurn && !input.responseUserId) {
178
+ return {
179
+ behavior: 'deny',
180
+ message: NATIVE_APPROVAL_UNAVAILABLE_MESSAGE,
181
+ };
182
+ }
177
183
  let diff;
178
184
  if (input.buildApprovalDiff) {
179
185
  try {
@@ -186,6 +192,7 @@ export async function decideClaudeToolPermissionForMode(input) {
186
192
  let result;
187
193
  try {
188
194
  const risk = classifyClaudeApprovalRisk(input.toolName, input.toolInput);
195
+ const allowSessionRule = input.allowSessionRule ?? input.isOwnerTurn;
189
196
  result = await input.approvalManager.requestApproval(input.conversationId, input.toolName, input.toolInput, {
190
197
  category: classifyClaudeApprovalCategory(input.toolName),
191
198
  risk,
@@ -198,8 +205,9 @@ export async function decideClaudeToolPermissionForMode(input) {
198
205
  },
199
206
  details: buildClaudeApprovalDetails(input.toolName, input.toolInput),
200
207
  ...(diff ? { diff } : {}),
201
- ignoreSessionRules: !input.isOwnerTurn,
202
- allowSessionRule: input.isOwnerTurn,
208
+ ...(input.responseUserId ? { responseUserId: input.responseUserId } : {}),
209
+ ignoreSessionRules: !allowSessionRule,
210
+ allowSessionRule,
203
211
  });
204
212
  }
205
213
  catch {
@@ -8,6 +8,8 @@ export interface ClaudeAskUserDialog {
8
8
  updatedInput: Record<string, unknown>;
9
9
  requestId?: string;
10
10
  }
11
+ export declare function resolveClaudeUserDialogRequestId(request: Pick<UserDialogRequest, 'toolUseID'>, dialog: Pick<ClaudeAskUserDialog, 'requestId'>): string | undefined;
12
+ export declare function createClaudeUserDialogCoordinator<T>(maxCompleted?: number): (requestId: string, run: () => Promise<T>) => Promise<T>;
11
13
  export declare function parseClaudeAskUserDialog(request: UserDialogRequest): ClaudeAskUserDialog | null;
12
14
  export declare function parseClaudeAskUserToolInput(input: Record<string, unknown>, requestId: string): ClaudeAskUserDialog | null;
13
15
  export declare function buildClaudeAskUserPermissionResult(dialog: ClaudeAskUserDialog, response: {
@@ -1,6 +1,40 @@
1
1
  import { resolveQuestionAllowOther, } from '@canonmsg/core';
2
2
  export const CLAUDE_ASK_USER_DIALOG_KIND = 'permission_ask_user_question';
3
3
  export const CLAUDE_SUPPORTED_DIALOG_KINDS = [CLAUDE_ASK_USER_DIALOG_KIND];
4
+ export function resolveClaudeUserDialogRequestId(request, dialog) {
5
+ const toolUseId = typeof request.toolUseID === 'string' ? request.toolUseID.trim() : '';
6
+ if (toolUseId)
7
+ return toolUseId.slice(0, 160);
8
+ const payloadRequestId = dialog.requestId?.trim();
9
+ return payloadRequestId ? payloadRequestId.slice(0, 160) : undefined;
10
+ }
11
+ export function createClaudeUserDialogCoordinator(maxCompleted = 64) {
12
+ const active = new Map();
13
+ const completed = new Map();
14
+ return async (requestId, run) => {
15
+ if (completed.has(requestId))
16
+ return completed.get(requestId);
17
+ const activeResult = active.get(requestId);
18
+ if (activeResult)
19
+ return activeResult;
20
+ const pending = Promise.resolve().then(run);
21
+ active.set(requestId, pending);
22
+ try {
23
+ const result = await pending;
24
+ completed.set(requestId, result);
25
+ while (completed.size > maxCompleted) {
26
+ const oldest = completed.keys().next().value;
27
+ if (oldest === undefined)
28
+ break;
29
+ completed.delete(oldest);
30
+ }
31
+ return result;
32
+ }
33
+ finally {
34
+ active.delete(requestId);
35
+ }
36
+ };
37
+ }
4
38
  function isRecord(value) {
5
39
  return typeof value === 'object' && value !== null && !Array.isArray(value);
6
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
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,9 +31,9 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "0.3.204",
34
- "@canonmsg/agent-sdk": "^5.1.0",
34
+ "@canonmsg/agent-sdk": "^5.1.1",
35
35
  "@canonmsg/coding-agent-host": "^0.2.2",
36
- "@canonmsg/core": "^4.2.1",
36
+ "@canonmsg/core": "^4.2.2",
37
37
  "@modelcontextprotocol/sdk": "^1.29.0"
38
38
  },
39
39
  "engines": {