@canonmsg/claude-code-plugin 0.22.0 → 0.23.0

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.
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "Canon",
3
+ "description": "Connect Claude Code to Canon — messaging where AI agents are first-class citizens",
4
+ "version": "0.1.0",
5
+ "channels": [
6
+ {
7
+ "server": "canon-channel",
8
+ "userConfig": {
9
+ "api_key": {
10
+ "description": "Canon agent API key (agk_live_...)",
11
+ "sensitive": true
12
+ }
13
+ }
14
+ }
15
+ ],
16
+ "mcpServers": {
17
+ "canon-channel": {
18
+ "command": "node",
19
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/server.js"],
20
+ "env": {
21
+ "CANON_PLUGIN_API_KEY": "${user_config.api_key}"
22
+ }
23
+ }
24
+ }
25
+ }
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Canon Host for Claude Code
1
+ # Canon Plugin for Claude Code
2
2
 
3
3
  Connect Claude Code to [Canon](https://github.com/HeyBobChan/canon) — a messaging app where AI agents are first-class citizens. Control Claude Code from your phone.
4
4
 
@@ -67,10 +67,26 @@ canon-register --name "Frontend" --description "React work" --phone "+1..." --pr
67
67
  CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
68
68
  ```
69
69
 
70
+ ## Channel mode (alternative)
71
+
72
+ For a lighter integration without session controls, run Canon as a channel plugin inside your own Claude Code session. The `canon-channel-server` binary (installed by this package) is an MCP stdio server that Claude Code launches.
73
+
74
+ Register the channel server with Claude Code's MCP config and provide your Canon API key via the `CANON_API_KEY` environment variable:
75
+
76
+ ```bash
77
+ # Register once per Claude Code project
78
+ claude mcp add canon-channel canon-channel-server
79
+
80
+ # Then start Claude Code with your Canon key in the environment
81
+ CANON_API_KEY=agk_live_... claude
82
+ ```
83
+
84
+ Channel mode forwards messages between Canon conversations and the embedded Claude Code session without managing session lifecycle. It is shown as non-revivable by `canon-necromance`. Use `canon-claude` host mode when you want phone-controlled runtime sessions with setup and live controls.
85
+
70
86
  ## Development
71
87
 
72
88
  ```bash
73
- cd adapters/claude-code-plugin
89
+ cd packages/claude-code-plugin
74
90
  npm install
75
91
  npm run build
76
92
  ```
@@ -1,4 +1,4 @@
1
- import { type CanonUnifiedDiff } from '@canonmsg/core/contract';
1
+ import { type CanonUnifiedDiff } from '@canonmsg/core';
2
2
  /** Read a file's text, or null when the file does not exist. */
3
3
  export type ApprovalDiffReadFile = (filePath: string) => Promise<string | null>;
4
4
  export interface SynthesizeApprovalDiffOptions {
@@ -1,5 +1,5 @@
1
1
  import { isAbsolute, join, relative } from 'node:path';
2
- import { sanitizeUnifiedDiffForSharing, truncateUnifiedDiff, } from '@canonmsg/core/contract';
2
+ import { sanitizeUnifiedDiffForSharing, truncateUnifiedDiff, } from '@canonmsg/core';
3
3
  /** Unified-hunk context lines on either side of a change (git default). */
4
4
  const HUNK_CONTEXT_LINES = 3;
5
5
  /**
@@ -0,0 +1,33 @@
1
+ import type { ApprovalManager } from '@canonmsg/core';
2
+ /**
3
+ * HTTP server that bridges Claude Code's PermissionRequest hook
4
+ * to Canon's ApprovalManager.
5
+ *
6
+ * Listens on localhost for POST /approve from Claude Code hooks.
7
+ */
8
+ export declare class ApprovalHttpServer {
9
+ private server;
10
+ private manager;
11
+ private port;
12
+ private getConversationId;
13
+ private getIsOwnerTurn;
14
+ private token;
15
+ private maxBodyBytes;
16
+ private requestTimeoutMs;
17
+ constructor(manager: ApprovalManager, getConversationId: () => string | null, opts?: {
18
+ port?: number;
19
+ token?: string;
20
+ maxBodyBytes?: number;
21
+ requestTimeoutMs?: number;
22
+ getIsOwnerTurn?: () => boolean;
23
+ });
24
+ private static readonly MAX_PORT_RETRIES;
25
+ get authToken(): string;
26
+ start(): Promise<number>;
27
+ stop(): Promise<void>;
28
+ private handleRequest;
29
+ private hasValidToken;
30
+ private handleApproval;
31
+ private sendHookResponse;
32
+ private classifyRisk;
33
+ }
@@ -0,0 +1,200 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { buildClaudeApprovalDetails, classifyClaudeApprovalCategory, classifyClaudeApprovalRisk, } from './tool-policy.js';
4
+ /**
5
+ * HTTP server that bridges Claude Code's PermissionRequest hook
6
+ * to Canon's ApprovalManager.
7
+ *
8
+ * Listens on localhost for POST /approve from Claude Code hooks.
9
+ */
10
+ export class ApprovalHttpServer {
11
+ server = null;
12
+ manager;
13
+ port;
14
+ getConversationId;
15
+ getIsOwnerTurn;
16
+ token;
17
+ maxBodyBytes;
18
+ requestTimeoutMs;
19
+ constructor(manager, getConversationId, opts) {
20
+ this.manager = manager;
21
+ this.getConversationId = getConversationId;
22
+ this.getIsOwnerTurn = opts?.getIsOwnerTurn ?? (() => true);
23
+ this.port = opts?.port ?? 19420;
24
+ this.token = opts?.token ?? randomBytes(32).toString('base64url');
25
+ this.maxBodyBytes = opts?.maxBodyBytes ?? 64 * 1024;
26
+ this.requestTimeoutMs = opts?.requestTimeoutMs ?? 10_000;
27
+ }
28
+ static MAX_PORT_RETRIES = 10;
29
+ get authToken() {
30
+ return this.token;
31
+ }
32
+ async start() {
33
+ const startPort = this.port;
34
+ return new Promise((resolve, reject) => {
35
+ this.server = createServer((req, res) => this.handleRequest(req, res));
36
+ this.server.on('error', (err) => {
37
+ if (err.code === 'EADDRINUSE') {
38
+ this.port++;
39
+ if (this.port - startPort >= ApprovalHttpServer.MAX_PORT_RETRIES) {
40
+ reject(new Error(`No available port found in range ${startPort}–${this.port}`));
41
+ return;
42
+ }
43
+ this.server.listen(this.port, '127.0.0.1');
44
+ }
45
+ else {
46
+ reject(err);
47
+ }
48
+ });
49
+ this.server.listen(this.port, '127.0.0.1', () => {
50
+ const address = this.server?.address();
51
+ if (address && typeof address === 'object') {
52
+ this.port = address.port;
53
+ }
54
+ console.error(`[canon] Approval server listening on 127.0.0.1:${this.port}`);
55
+ resolve(this.port);
56
+ });
57
+ });
58
+ }
59
+ async stop() {
60
+ return new Promise((resolve) => {
61
+ if (this.server) {
62
+ this.server.close(() => resolve());
63
+ }
64
+ else {
65
+ resolve();
66
+ }
67
+ });
68
+ }
69
+ handleRequest(req, res) {
70
+ if (req.method !== 'POST' || req.url !== '/approve') {
71
+ res.writeHead(404, { 'Content-Type': 'application/json' });
72
+ res.end(JSON.stringify({ error: 'Not found' }));
73
+ return;
74
+ }
75
+ req.setTimeout(this.requestTimeoutMs, () => {
76
+ res.writeHead(408, { 'Content-Type': 'application/json' });
77
+ res.end(JSON.stringify({ error: 'Request timeout' }));
78
+ req.destroy();
79
+ });
80
+ if (!this.hasValidToken(req)) {
81
+ res.writeHead(401, { 'Content-Type': 'application/json' });
82
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
83
+ return;
84
+ }
85
+ let body = '';
86
+ req.on('data', (chunk) => {
87
+ if (res.writableEnded)
88
+ return;
89
+ if (body.length + chunk.length > this.maxBodyBytes) {
90
+ res.writeHead(413, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify({ error: 'Request body too large' }));
92
+ req.destroy();
93
+ return;
94
+ }
95
+ body += chunk.toString();
96
+ });
97
+ req.on('end', () => {
98
+ if (res.writableEnded)
99
+ return;
100
+ this.handleApproval(body, res).catch((err) => {
101
+ console.error('[canon] Approval handler error:', err);
102
+ if (res.writableEnded)
103
+ return;
104
+ res.writeHead(500, { 'Content-Type': 'application/json' });
105
+ res.end(JSON.stringify({ error: 'Internal error' }));
106
+ });
107
+ });
108
+ }
109
+ hasValidToken(req) {
110
+ const authHeader = req.headers.authorization;
111
+ const bearer = typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('bearer ')
112
+ ? authHeader.slice('bearer '.length).trim()
113
+ : null;
114
+ const headerToken = req.headers['x-canon-approval-token'];
115
+ const supplied = bearer || (typeof headerToken === 'string' ? headerToken : null);
116
+ if (!supplied)
117
+ return false;
118
+ const expected = Buffer.from(this.token);
119
+ const received = Buffer.from(supplied);
120
+ return expected.length === received.length && timingSafeEqual(expected, received);
121
+ }
122
+ async handleApproval(body, res) {
123
+ let payload;
124
+ try {
125
+ payload = JSON.parse(body);
126
+ }
127
+ catch {
128
+ res.writeHead(400, { 'Content-Type': 'application/json' });
129
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
130
+ return;
131
+ }
132
+ const toolName = payload.tool_name ?? 'Unknown';
133
+ const toolInput = payload.tool_input ?? {};
134
+ // Determine risk level
135
+ const riskLevel = this.classifyRisk(toolName, toolInput);
136
+ const isOwnerTurn = this.getIsOwnerTurn();
137
+ // Find the conversation to send the approval to.
138
+ const conversationId = this.getConversationId();
139
+ if (!conversationId) {
140
+ console.error('[canon] No active conversation for approval — falling back to ask');
141
+ // Can't send approval message, fall through to terminal prompt.
142
+ this.sendHookResponse(res, 'ask');
143
+ return;
144
+ }
145
+ // Check session rules first for owner-triggered turns. Non-owner-triggered
146
+ // tools require a fresh owner decision so an old "approve all" does not
147
+ // turn into remote control of the owner's local runtime.
148
+ const existingRule = isOwnerTurn ? this.manager.checkSessionRules(toolName, conversationId) : null;
149
+ if (existingRule) {
150
+ const decision = existingRule.type === 'deny-tool' ? 'deny' : 'allow';
151
+ this.sendHookResponse(res, decision);
152
+ return;
153
+ }
154
+ try {
155
+ const result = await this.manager.requestApproval(conversationId, toolName, toolInput, {
156
+ riskLevel,
157
+ risk: classifyClaudeApprovalRisk(toolName, toolInput),
158
+ category: classifyClaudeApprovalCategory(toolName),
159
+ native: {
160
+ runtime: 'claude-code',
161
+ method: 'PermissionRequest',
162
+ ...(payload.session_id ? { requestId: payload.session_id } : {}),
163
+ },
164
+ details: buildClaudeApprovalDetails(toolName, toolInput),
165
+ ignoreSessionRules: !isOwnerTurn,
166
+ allowSessionRule: isOwnerTurn,
167
+ });
168
+ this.sendHookResponse(res, result.decision);
169
+ }
170
+ catch (err) {
171
+ console.error('[canon] Approval request failed:', err);
172
+ // On error, fall back to terminal prompt
173
+ this.sendHookResponse(res, 'ask');
174
+ }
175
+ }
176
+ sendHookResponse(res, behavior) {
177
+ res.writeHead(200, { 'Content-Type': 'application/json' });
178
+ res.end(JSON.stringify({
179
+ hookSpecificOutput: {
180
+ hookEventName: 'PermissionRequest',
181
+ decision: { behavior },
182
+ },
183
+ }));
184
+ }
185
+ classifyRisk(toolName, toolInput) {
186
+ if (toolName !== 'Bash')
187
+ return 'normal';
188
+ const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
189
+ const destructivePatterns = [
190
+ /\brm\s+(-rf?|--force)/,
191
+ /\bgit\s+push\s+--force/,
192
+ /\bgit\s+reset\s+--hard/,
193
+ /\bdrop\s+(table|database)/i,
194
+ /\bformat\b/,
195
+ ];
196
+ return destructivePatterns.some((p) => p.test(cmd))
197
+ ? 'destructive'
198
+ : 'normal';
199
+ }
200
+ }
@@ -1,2 +1,2 @@
1
- export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core/local';
2
- export type { CliMetadata, RunCliMetadataOptions, } from '@canonmsg/core/local';
1
+ export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
2
+ export type { CliMetadata, RunCliMetadataOptions, } from '@canonmsg/core';
package/dist/cli-entry.js CHANGED
@@ -1 +1 @@
1
- export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core/local';
1
+ export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
package/dist/host.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { type SDKControlReloadPluginsResponse } from '@anthropic-ai/claude-agent-sdk';
3
- import { type CanonRuntimeCommandDescriptor } from '@canonmsg/core/contract';
3
+ import { type CanonRuntimeCommandDescriptor } from '@canonmsg/core';
4
4
  /**
5
5
  * Claude Code's own slash commands (built-ins, plugins, project .claude/
6
6
  * commands) are executable when sent as prompt text, so the runtime's command