@canonmsg/claude-code-plugin 0.25.0 → 0.25.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.
@@ -13,6 +13,7 @@ export interface ClaudeInputEnvelope {
13
13
  artifactBaseline: TurnArtifactSnapshot | null;
14
14
  turnKey: string;
15
15
  isOwnerTurn: boolean;
16
+ requestingUserId: string | null;
16
17
  }
17
18
  export interface ClaudeRuntimeControlError {
18
19
  value: string;
@@ -48,6 +49,7 @@ export declare function createClaudeInputEnvelope(input: {
48
49
  artifactRoutingMode?: ClaudeArtifactRoutingMode;
49
50
  artifactBaseline?: TurnArtifactSnapshot | null;
50
51
  isOwnerTurn?: boolean;
52
+ requestingUserId?: string | null;
51
53
  }): ClaudeInputEnvelope;
52
54
  export declare function buildClaudeFinalMessageId(input: {
53
55
  agentId: string;
@@ -60,12 +62,23 @@ export declare function shouldDeliverClaudeFinal(input: {
60
62
  interruptedTurnKeys: ReadonlySet<string>;
61
63
  }): boolean;
62
64
  export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
63
- export declare function mergeClaudeModelOptions(input: {
64
- previous: ReadonlyArray<ModelOption>;
65
- next: ReadonlyArray<ModelOption>;
65
+ export interface ClaudeModelInfoLike {
66
+ value: string;
67
+ displayName: string;
68
+ description?: string;
69
+ }
70
+ export declare function claudeModelInfoToOption(model: ClaudeModelInfoLike): ModelOption;
71
+ export declare function isClaudeCustomModelOption(option: ModelOption | null | undefined): boolean;
72
+ export declare function mergeClaudeDiscoveredModelOptions(...groups: ReadonlyArray<ReadonlyArray<ModelOption>>): ModelOption[];
73
+ export declare function resolveClaudeModelOptions(input: {
74
+ discovered: ReadonlyArray<ModelOption>;
66
75
  selectedModel?: string | null;
67
76
  }): ModelOption[];
68
77
  export declare function formatClaudeControlError(error: unknown): string;
78
+ export declare function confirmClaudeInterrupt(input: {
79
+ active: boolean;
80
+ interrupt: () => Promise<void>;
81
+ }): Promise<'confirmed' | 'defer'>;
69
82
  /** Requested session-control values from the control plane (raw, pre-validation). */
70
83
  export interface ClaudeSessionControlInput {
71
84
  model?: string;
@@ -13,6 +13,9 @@ export function createClaudeInputEnvelope(input) {
13
13
  isOwnerTurn: input.kind === 'canon'
14
14
  ? input.isOwnerTurn === true
15
15
  : input.isOwnerTurn !== false,
16
+ requestingUserId: input.kind === 'canon' && input.requestingUserId
17
+ ? input.requestingUserId
18
+ : null,
16
19
  turnKey: input.kind === 'canon' && sourceMessageId
17
20
  ? `canon:${sourceMessageId}`
18
21
  : `${input.kind}:${fallbackId}`,
@@ -46,32 +49,68 @@ export function resetClaudeCompletedTurnState(session) {
46
49
  session.toolInProgress = false;
47
50
  session.turnState = 'idle';
48
51
  }
49
- export function mergeClaudeModelOptions(input) {
52
+ function normalizeModelDescription(value) {
53
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
54
+ }
55
+ function displayLabelWithClaudeVersion(input) {
56
+ const label = input.displayName.trim() || input.value;
57
+ const description = normalizeModelDescription(input.description);
58
+ if (!description || label.includes('(') || /\d/.test(label))
59
+ return label;
60
+ const [summary] = description.split('·');
61
+ const versionedLabel = summary?.trim();
62
+ return versionedLabel
63
+ && versionedLabel.toLowerCase().startsWith(label.toLowerCase())
64
+ && /\d/.test(versionedLabel)
65
+ ? versionedLabel
66
+ : label;
67
+ }
68
+ export function claudeModelInfoToOption(model) {
69
+ const description = normalizeModelDescription(model.description);
70
+ return {
71
+ value: model.value,
72
+ label: displayLabelWithClaudeVersion(model),
73
+ ...(description ? { description } : {}),
74
+ };
75
+ }
76
+ export function isClaudeCustomModelOption(option) {
77
+ return option?.description?.trim().toLowerCase() === 'custom model';
78
+ }
79
+ export function mergeClaudeDiscoveredModelOptions(...groups) {
50
80
  const byValue = new Map();
51
- for (const option of input.previous) {
52
- byValue.set(option.value, { ...option });
81
+ const valueByLabel = new Map();
82
+ const labelByValue = new Map();
83
+ for (const group of groups) {
84
+ for (const option of group) {
85
+ const previousLabel = labelByValue.get(option.value);
86
+ if (previousLabel && valueByLabel.get(previousLabel) === option.value) {
87
+ valueByLabel.delete(previousLabel);
88
+ }
89
+ const duplicateValue = valueByLabel.get(option.label);
90
+ if (duplicateValue && duplicateValue !== option.value && duplicateValue !== 'default') {
91
+ byValue.delete(duplicateValue);
92
+ labelByValue.delete(duplicateValue);
93
+ }
94
+ byValue.set(option.value, option);
95
+ valueByLabel.set(option.label, option.value);
96
+ labelByValue.set(option.value, option.label);
97
+ }
53
98
  }
54
- for (const option of input.next) {
99
+ return [...byValue.values()];
100
+ }
101
+ export function resolveClaudeModelOptions(input) {
102
+ const byValue = new Map();
103
+ for (const option of input.discovered) {
55
104
  byValue.set(option.value, { ...option });
56
105
  }
57
106
  if (input.selectedModel && !byValue.has(input.selectedModel)) {
58
107
  byValue.set(input.selectedModel, {
59
108
  value: input.selectedModel,
60
109
  label: input.selectedModel,
61
- description: 'Selected for this Canon session but not returned by the latest Claude model probe.',
110
+ description: 'Selected for this Canon session but not returned by Claude model discovery.',
62
111
  });
63
112
  }
64
- const nextValues = new Set(input.next.map((option) => option.value));
65
- return [...byValue.values()].map((option) => {
66
- if (nextValues.has(option.value))
67
- return option;
68
- return {
69
- ...option,
70
- description: option.description
71
- ? `${option.description} Previously advertised; not returned by the latest Claude model probe.`
72
- : 'Previously advertised; not returned by the latest Claude model probe.',
73
- };
74
- });
113
+ return [...byValue.values()];
75
114
  }
76
115
  export function formatClaudeControlError(error) {
77
116
  if (error instanceof Error && error.message.trim()) {
@@ -82,6 +121,17 @@ export function formatClaudeControlError(error) {
82
121
  }
83
122
  return 'Claude Code did not apply that control.';
84
123
  }
124
+ export async function confirmClaudeInterrupt(input) {
125
+ if (!input.active)
126
+ return 'confirmed';
127
+ try {
128
+ await input.interrupt();
129
+ return 'confirmed';
130
+ }
131
+ catch {
132
+ return 'defer';
133
+ }
134
+ }
85
135
  /**
86
136
  * Apply a session-control request to the live Claude session. Mirrors the host's
87
137
  * control semantics (validation, only-if-different checks, error recording,
@@ -6,9 +6,8 @@
6
6
  * its `before` message-id parameter) until the cursor is found or a hard
7
7
  * per-conversation bound is hit, then replay everything after the cursor.
8
8
  *
9
- * This module is intentionally identical in packages/claude-code-plugin and
10
- * packages/codex-plugin — keep both copies in sync (future consolidation
11
- * candidate).
9
+ * Claude also accepts legacy cursor candidates so it can migrate old
10
+ * execution-mode-scoped state into one conversation-level recovery cursor.
12
11
  */
13
12
  export declare const STARTUP_RECOVERY_PAGE_SIZE = 25;
14
13
  export declare const STARTUP_RECOVERY_MAX_MESSAGES = 500;
@@ -16,6 +15,7 @@ export interface StartupRecoveryMessage {
16
15
  id: string;
17
16
  senderId: string;
18
17
  createdAt?: string;
18
+ metadata?: Record<string, unknown>;
19
19
  }
20
20
  export interface StartupRecoveryPage {
21
21
  messages: StartupRecoveryMessage[];
@@ -33,6 +33,8 @@ export type StartupRecoveryMode =
33
33
  | 'latest-only';
34
34
  export interface StartupRecoveryResult<TPage extends StartupRecoveryPage> {
35
35
  mode: StartupRecoveryMode;
36
+ /** Cursor found in history and used for this recovery pass. */
37
+ cursor: string | null;
36
38
  /** Missed inbound messages (own messages excluded), oldest first. */
37
39
  messages: TPage['messages'];
38
40
  /** First page fetched — reusable as hydration context for recovered turns. */
@@ -41,6 +43,7 @@ export interface StartupRecoveryResult<TPage extends StartupRecoveryPage> {
41
43
  export declare function collectMissedInboundMessages<TPage extends StartupRecoveryPage>(input: {
42
44
  fetchPage: (before?: string) => Promise<TPage>;
43
45
  cursor: string | null | undefined;
46
+ cursorCandidates?: readonly string[];
44
47
  agentId: string;
45
48
  maxMessages?: number;
46
49
  }): Promise<StartupRecoveryResult<TPage>>;
@@ -6,9 +6,8 @@
6
6
  * its `before` message-id parameter) until the cursor is found or a hard
7
7
  * per-conversation bound is hit, then replay everything after the cursor.
8
8
  *
9
- * This module is intentionally identical in packages/claude-code-plugin and
10
- * packages/codex-plugin — keep both copies in sync (future consolidation
11
- * candidate).
9
+ * Claude also accepts legacy cursor candidates so it can migrate old
10
+ * execution-mode-scoped state into one conversation-level recovery cursor.
12
11
  */
13
12
  export const STARTUP_RECOVERY_PAGE_SIZE = 25;
14
13
  export const STARTUP_RECOVERY_MAX_MESSAGES = 500;
@@ -17,10 +16,21 @@ export async function collectMissedInboundMessages(input) {
17
16
  const newestPage = await input.fetchPage();
18
17
  const collected = [...newestPage.messages];
19
18
  const seenIds = new Set(collected.map((message) => message.id));
20
- const hasCursor = (messages) => input.cursor != null && messages.some((message) => message.id === input.cursor);
21
- let cursorFound = hasCursor(collected);
22
- if (input.cursor != null) {
23
- while (!cursorFound && collected.length < maxMessages) {
19
+ const candidateCursorIds = new Set(input.cursor
20
+ ? [input.cursor]
21
+ : (input.cursorCandidates ?? []).filter(Boolean));
22
+ const foundCursorIds = new Set();
23
+ const recordFoundCursors = (messages) => {
24
+ for (const message of messages) {
25
+ if (candidateCursorIds.has(message.id))
26
+ foundCursorIds.add(message.id);
27
+ }
28
+ };
29
+ const cursorSearchComplete = () => foundCursorIds.size > 0;
30
+ const findNewestCursor = (messages) => messages.find((message) => candidateCursorIds.has(message.id))?.id ?? null;
31
+ recordFoundCursors(collected);
32
+ if (candidateCursorIds.size > 0) {
33
+ while (!cursorSearchComplete() && collected.length < maxMessages) {
24
34
  // Pages are newest-first, so the last collected message is the oldest.
25
35
  const before = collected[collected.length - 1]?.id;
26
36
  if (!before)
@@ -34,19 +44,32 @@ export async function collectMissedInboundMessages(input) {
34
44
  for (const message of fresh)
35
45
  seenIds.add(message.id);
36
46
  collected.push(...fresh);
37
- cursorFound = hasCursor(fresh);
47
+ recordFoundCursors(fresh);
38
48
  }
39
49
  }
50
+ const resolvedCursor = cursorSearchComplete()
51
+ ? findNewestCursor(collected)
52
+ : null;
40
53
  const ascending = [...collected].sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
41
- const inboundOnly = (messages) => messages.filter((message) => message.senderId !== input.agentId);
54
+ const completedSourceMessageIds = new Set(collected.flatMap((message) => {
55
+ if (message.senderId !== input.agentId)
56
+ return [];
57
+ const metadata = message.metadata;
58
+ if (metadata?.turnSemantics !== 'turn_complete')
59
+ return [];
60
+ return typeof metadata.sourceMessageId === 'string'
61
+ ? [metadata.sourceMessageId]
62
+ : [];
63
+ }));
64
+ const inboundOnly = (messages) => messages.filter((message) => message.senderId !== input.agentId && !completedSourceMessageIds.has(message.id));
42
65
  let mode;
43
66
  let missed;
44
- if (cursorFound) {
45
- const cursorIndex = ascending.findIndex((message) => message.id === input.cursor);
67
+ if (resolvedCursor) {
68
+ const cursorIndex = ascending.findIndex((message) => message.id === resolvedCursor);
46
69
  mode = 'after-cursor';
47
70
  missed = inboundOnly(ascending.slice(cursorIndex + 1));
48
71
  }
49
- else if (input.cursor != null && collected.length >= maxMessages) {
72
+ else if (candidateCursorIds.size > 0 && collected.length >= maxMessages) {
50
73
  mode = 'truncated-window';
51
74
  missed = inboundOnly(ascending.slice(-maxMessages));
52
75
  }
@@ -55,5 +78,10 @@ export async function collectMissedInboundMessages(input) {
55
78
  missed = inboundOnly(ascending).slice(-1);
56
79
  }
57
80
  // Safe: `missed` only holds elements of pages returned by `fetchPage`.
58
- return { mode, messages: missed, newestPage };
81
+ return {
82
+ mode,
83
+ cursor: resolvedCursor,
84
+ messages: missed,
85
+ newestPage,
86
+ };
59
87
  }
@@ -0,0 +1,17 @@
1
+ import type { PermissionResult, UserDialogRequest } from '@anthropic-ai/claude-agent-sdk';
2
+ import { type RuntimeInputAnswers, type RuntimeInputQuestion } from '@canonmsg/core';
3
+ export declare const CLAUDE_ASK_USER_DIALOG_KIND = "permission_ask_user_question";
4
+ export declare const CLAUDE_SUPPORTED_DIALOG_KINDS: string[];
5
+ export interface ClaudeAskUserDialog {
6
+ questions: RuntimeInputQuestion[];
7
+ rawQuestions: Array<Record<string, unknown>>;
8
+ updatedInput: Record<string, unknown>;
9
+ requestId?: string;
10
+ }
11
+ export declare function parseClaudeAskUserDialog(request: UserDialogRequest): ClaudeAskUserDialog | null;
12
+ export declare function parseClaudeAskUserToolInput(input: Record<string, unknown>, requestId: string): ClaudeAskUserDialog | null;
13
+ export declare function buildClaudeAskUserPermissionResult(dialog: ClaudeAskUserDialog, response: {
14
+ value?: string;
15
+ answers?: RuntimeInputAnswers;
16
+ }): PermissionResult;
17
+ export declare function buildClaudeAskUserPermissionDenied(message: string): PermissionResult;
@@ -0,0 +1,117 @@
1
+ import { resolveQuestionAllowOther, } from '@canonmsg/core';
2
+ export const CLAUDE_ASK_USER_DIALOG_KIND = 'permission_ask_user_question';
3
+ export const CLAUDE_SUPPORTED_DIALOG_KINDS = [CLAUDE_ASK_USER_DIALOG_KIND];
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ export function parseClaudeAskUserDialog(request) {
8
+ if (request.dialogKind !== CLAUDE_ASK_USER_DIALOG_KIND)
9
+ return null;
10
+ const rawQuestions = Array.isArray(request.payload.questions)
11
+ ? request.payload.questions.filter(isRecord).slice(0, 12)
12
+ : [];
13
+ if (rawQuestions.length === 0)
14
+ return null;
15
+ const questions = rawQuestions.flatMap((record, index) => {
16
+ const question = typeof record.question === 'string' && record.question.trim()
17
+ ? record.question.trim().slice(0, 1000)
18
+ : null;
19
+ if (!question)
20
+ return [];
21
+ const slug = question.slice(0, 72).replace(/[^A-Za-z0-9_.:-]+/g, '-').replace(/^-+|-+$/g, '');
22
+ const id = `q-${index + 1}${slug ? `-${slug}` : ''}`.slice(0, 120);
23
+ const header = typeof record.header === 'string' && record.header.trim()
24
+ ? record.header.trim().slice(0, 120)
25
+ : undefined;
26
+ const choices = (Array.isArray(record.options) ? record.options : [])
27
+ .filter(isRecord)
28
+ .slice(0, 12)
29
+ .flatMap((option) => {
30
+ const label = typeof option.label === 'string' && option.label.trim()
31
+ ? option.label.trim().slice(0, 120)
32
+ : typeof option.value === 'string' && option.value.trim()
33
+ ? option.value.trim().slice(0, 120)
34
+ : null;
35
+ if (!label)
36
+ return [];
37
+ const value = typeof option.value === 'string' && option.value.trim()
38
+ ? option.value.trim().slice(0, 120)
39
+ : label;
40
+ const description = typeof option.description === 'string' && option.description.trim()
41
+ ? option.description.trim().slice(0, 240)
42
+ : undefined;
43
+ const preview = typeof option.preview === 'string' && option.preview.trim()
44
+ ? option.preview.trim().slice(0, 4000)
45
+ : undefined;
46
+ return [{
47
+ label,
48
+ value,
49
+ ...(description ? { description } : {}),
50
+ ...(preview ? { preview } : {}),
51
+ }];
52
+ });
53
+ return [{
54
+ id,
55
+ question,
56
+ ...(header ? { header } : {}),
57
+ ...(choices.length > 0 ? { choices } : {}),
58
+ ...(resolveQuestionAllowOther(choices.length > 0, record.allowOther ?? record.isOther)
59
+ ? { allowOther: true }
60
+ : {}),
61
+ ...(record.isSecret === true ? { isSecret: true } : {}),
62
+ ...(record.multiSelect === true ? { multiSelect: true } : {}),
63
+ }];
64
+ });
65
+ if (questions.length === 0)
66
+ return null;
67
+ const permissionResult = isRecord(request.payload.permissionResult)
68
+ ? request.payload.permissionResult
69
+ : null;
70
+ const updatedInput = isRecord(request.payload.input)
71
+ ? request.payload.input
72
+ : permissionResult && isRecord(permissionResult.updatedInput)
73
+ ? permissionResult.updatedInput
74
+ : { questions: rawQuestions };
75
+ const metadataSource = typeof request.payload.metadataSource === 'string'
76
+ ? request.payload.metadataSource.trim()
77
+ : '';
78
+ const inputWithMetadata = metadataSource && !isRecord(updatedInput.metadata)
79
+ ? { ...updatedInput, metadata: { source: metadataSource } }
80
+ : updatedInput;
81
+ const requestId = typeof request.payload.requestId === 'string' && request.payload.requestId.trim()
82
+ ? request.payload.requestId.trim().slice(0, 160)
83
+ : undefined;
84
+ return { questions, rawQuestions, updatedInput: inputWithMetadata, ...(requestId ? { requestId } : {}) };
85
+ }
86
+ export function parseClaudeAskUserToolInput(input, requestId) {
87
+ return parseClaudeAskUserDialog({
88
+ dialogKind: CLAUDE_ASK_USER_DIALOG_KIND,
89
+ payload: { requestId, questions: input.questions, input },
90
+ toolUseID: requestId,
91
+ });
92
+ }
93
+ export function buildClaudeAskUserPermissionResult(dialog, response) {
94
+ const answers = {};
95
+ dialog.questions.forEach((question, index) => {
96
+ const answer = response.answers?.[question.id]?.answers.join(', ').trim();
97
+ const fallback = dialog.questions.length === 1 ? response.value?.trim() : undefined;
98
+ if (answer || fallback) {
99
+ const rawQuestion = dialog.rawQuestions[index]?.question;
100
+ const key = typeof rawQuestion === 'string' && rawQuestion.trim()
101
+ ? rawQuestion
102
+ : question.question;
103
+ answers[key] = answer || fallback || '';
104
+ }
105
+ });
106
+ return {
107
+ behavior: 'allow',
108
+ updatedInput: {
109
+ ...dialog.updatedInput,
110
+ questions: dialog.rawQuestions,
111
+ answers,
112
+ },
113
+ };
114
+ }
115
+ export function buildClaudeAskUserPermissionDenied(message) {
116
+ return { behavior: 'deny', message };
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.25.0",
3
+ "version": "0.25.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",
@@ -30,10 +30,10 @@
30
30
  "test": "vitest run"
31
31
  },
32
32
  "dependencies": {
33
- "@anthropic-ai/claude-agent-sdk": "^0.3.200",
33
+ "@anthropic-ai/claude-agent-sdk": "0.3.204",
34
34
  "@canonmsg/agent-sdk": "^5.1.0",
35
35
  "@canonmsg/coding-agent-host": "^0.2.2",
36
- "@canonmsg/core": "^4.2.0",
36
+ "@canonmsg/core": "^4.2.1",
37
37
  "@modelcontextprotocol/sdk": "^1.29.0"
38
38
  },
39
39
  "engines": {