@canonmsg/claude-code-plugin 0.3.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 ADDED
@@ -0,0 +1,52 @@
1
+ # Canon Plugin for Claude Code
2
+
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
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ # Install
9
+ npm install -g @canonmsg/claude-code-plugin
10
+
11
+ # Register (approve in Canon app when prompted)
12
+ canon-register --name "My Claude" --description "My Claude Code agent" --phone "+15551234567"
13
+
14
+ # Run
15
+ canon-claude
16
+ ```
17
+
18
+ Send a message to your agent in the Canon app. Claude Code responds with full capabilities (file read/write, bash, etc.).
19
+
20
+ ## Features
21
+
22
+ - **Two-way messaging** — Messages from Canon flow to Claude Code and back
23
+ - **Session controls** — Switch model (Haiku/Sonnet/Opus) and permission mode from the app
24
+ - **Live streaming** — See Claude's response as it types
25
+ - **Interrupt** — Stop Claude mid-response from the app
26
+ - **Context meter** — See context window usage in the app
27
+ - **Max plan auth** — Uses your Claude subscription, no API key billing
28
+
29
+ ## Working directory
30
+
31
+ ```bash
32
+ canon-claude --cwd /path/to/project
33
+ ```
34
+
35
+ ## Multiple agents
36
+
37
+ ```bash
38
+ canon-register --name "Frontend" --description "React work" --phone "+1..." --profile frontend
39
+ CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
40
+ ```
41
+
42
+ ## Channel mode (alternative)
43
+
44
+ For a lighter integration without session controls, run Canon as a channel plugin inside your own Claude Code session. See [full setup guide](../../docs/claude-code-setup.md#channel-mode-alternative).
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ cd packages/claude-code-plugin
50
+ npm install
51
+ npm run build
52
+ ```
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared agent profile management — loading, locking, and resolution.
3
+ * Used by both host.ts and server.ts.
4
+ */
5
+ export interface AgentProfile {
6
+ apiKey: string;
7
+ agentId: string;
8
+ agentName: string;
9
+ registeredAt: string;
10
+ }
11
+ export declare const CANON_DIR: string;
12
+ export declare const AGENTS_PATH: string;
13
+ export declare const LOCKS_DIR: string;
14
+ export declare function loadProfiles(): Record<string, AgentProfile>;
15
+ export declare function isProcessAlive(pid: number): boolean;
16
+ export declare function isProfileLocked(profile: string): {
17
+ locked: boolean;
18
+ pid?: number;
19
+ };
20
+ export declare function acquireLock(profile: string): void;
21
+ export declare function releaseLock(profile: string): void;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared agent profile management — loading, locking, and resolution.
3
+ * Used by both host.ts and server.ts.
4
+ */
5
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ // ── Paths ────────────────────────────────────────────────────────────
9
+ export const CANON_DIR = join(homedir(), '.canon');
10
+ export const AGENTS_PATH = join(CANON_DIR, 'agents.json');
11
+ export const LOCKS_DIR = join(CANON_DIR, 'locks');
12
+ // ── Profile loading ──────────────────────────────────────────────────
13
+ export function loadProfiles() {
14
+ try {
15
+ return JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ // ── Process locking ──────────────────────────────────────────────────
22
+ export function isProcessAlive(pid) {
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ export function isProfileLocked(profile) {
32
+ const lockPath = join(LOCKS_DIR, `${profile}.lock`);
33
+ try {
34
+ const pid = parseInt(readFileSync(lockPath, 'utf-8').trim());
35
+ if (isProcessAlive(pid))
36
+ return { locked: true, pid };
37
+ // Stale lock — clean it up
38
+ try {
39
+ unlinkSync(lockPath);
40
+ }
41
+ catch { }
42
+ }
43
+ catch {
44
+ // No lock file
45
+ }
46
+ return { locked: false };
47
+ }
48
+ export function acquireLock(profile) {
49
+ mkdirSync(LOCKS_DIR, { recursive: true });
50
+ const lockPath = join(LOCKS_DIR, `${profile}.lock`);
51
+ const check = isProfileLocked(profile);
52
+ if (check.locked) {
53
+ throw new Error(`Agent "${profile}" is in use by another session (PID ${check.pid})`);
54
+ }
55
+ writeFileSync(lockPath, String(process.pid));
56
+ }
57
+ export function releaseLock(profile) {
58
+ const lockPath = join(LOCKS_DIR, `${profile}.lock`);
59
+ try {
60
+ unlinkSync(lockPath);
61
+ }
62
+ catch { }
63
+ }
@@ -0,0 +1,23 @@
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
+ constructor(manager: ApprovalManager, getConversationId: () => string | null, opts?: {
14
+ port?: number;
15
+ });
16
+ private static readonly MAX_PORT_RETRIES;
17
+ start(): Promise<number>;
18
+ stop(): Promise<void>;
19
+ private handleRequest;
20
+ private handleApproval;
21
+ private sendHookResponse;
22
+ private classifyRisk;
23
+ }
@@ -0,0 +1,133 @@
1
+ import { createServer } from 'node:http';
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 class ApprovalHttpServer {
9
+ server = null;
10
+ manager;
11
+ port;
12
+ getConversationId;
13
+ constructor(manager, getConversationId, opts) {
14
+ this.manager = manager;
15
+ this.getConversationId = getConversationId;
16
+ this.port = opts?.port ?? 19420;
17
+ }
18
+ static MAX_PORT_RETRIES = 10;
19
+ async start() {
20
+ const startPort = this.port;
21
+ return new Promise((resolve, reject) => {
22
+ this.server = createServer((req, res) => this.handleRequest(req, res));
23
+ this.server.on('error', (err) => {
24
+ if (err.code === 'EADDRINUSE') {
25
+ this.port++;
26
+ if (this.port - startPort >= ApprovalHttpServer.MAX_PORT_RETRIES) {
27
+ reject(new Error(`No available port found in range ${startPort}–${this.port}`));
28
+ return;
29
+ }
30
+ this.server.listen(this.port, '127.0.0.1');
31
+ }
32
+ else {
33
+ reject(err);
34
+ }
35
+ });
36
+ this.server.listen(this.port, '127.0.0.1', () => {
37
+ console.error(`[canon] Approval server listening on 127.0.0.1:${this.port}`);
38
+ resolve(this.port);
39
+ });
40
+ });
41
+ }
42
+ async stop() {
43
+ return new Promise((resolve) => {
44
+ if (this.server) {
45
+ this.server.close(() => resolve());
46
+ }
47
+ else {
48
+ resolve();
49
+ }
50
+ });
51
+ }
52
+ handleRequest(req, res) {
53
+ if (req.method !== 'POST' || req.url !== '/approve') {
54
+ res.writeHead(404, { 'Content-Type': 'application/json' });
55
+ res.end(JSON.stringify({ error: 'Not found' }));
56
+ return;
57
+ }
58
+ let body = '';
59
+ req.on('data', (chunk) => {
60
+ body += chunk.toString();
61
+ });
62
+ req.on('end', () => {
63
+ this.handleApproval(body, res).catch((err) => {
64
+ console.error('[canon] Approval handler error:', err);
65
+ res.writeHead(500, { 'Content-Type': 'application/json' });
66
+ res.end(JSON.stringify({ error: 'Internal error' }));
67
+ });
68
+ });
69
+ }
70
+ async handleApproval(body, res) {
71
+ let payload;
72
+ try {
73
+ payload = JSON.parse(body);
74
+ }
75
+ catch {
76
+ res.writeHead(400, { 'Content-Type': 'application/json' });
77
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
78
+ return;
79
+ }
80
+ const toolName = payload.tool_name ?? 'Unknown';
81
+ const toolInput = payload.tool_input ?? {};
82
+ // Determine risk level
83
+ const riskLevel = this.classifyRisk(toolName, toolInput);
84
+ // Check session rules first (may resolve immediately)
85
+ const existingRule = this.manager.checkSessionRules(toolName);
86
+ if (existingRule) {
87
+ const decision = existingRule.type === 'deny-tool' ? 'deny' : 'allow';
88
+ this.sendHookResponse(res, decision);
89
+ return;
90
+ }
91
+ // Find the conversation to send the approval to
92
+ const conversationId = this.getConversationId();
93
+ if (!conversationId) {
94
+ console.error('[canon] No active conversation for approval — falling back to allow');
95
+ // Can't send approval message, fall through to terminal prompt
96
+ this.sendHookResponse(res, 'ask');
97
+ return;
98
+ }
99
+ try {
100
+ const result = await this.manager.requestApproval(conversationId, toolName, toolInput, { riskLevel });
101
+ this.sendHookResponse(res, result.decision);
102
+ }
103
+ catch (err) {
104
+ console.error('[canon] Approval request failed:', err);
105
+ // On error, fall back to terminal prompt
106
+ this.sendHookResponse(res, 'ask');
107
+ }
108
+ }
109
+ sendHookResponse(res, behavior) {
110
+ res.writeHead(200, { 'Content-Type': 'application/json' });
111
+ res.end(JSON.stringify({
112
+ hookSpecificOutput: {
113
+ hookEventName: 'PermissionRequest',
114
+ decision: { behavior },
115
+ },
116
+ }));
117
+ }
118
+ classifyRisk(toolName, toolInput) {
119
+ if (toolName !== 'Bash')
120
+ return 'normal';
121
+ const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
122
+ const destructivePatterns = [
123
+ /\brm\s+(-rf?|--force)/,
124
+ /\bgit\s+push\s+--force/,
125
+ /\bgit\s+reset\s+--hard/,
126
+ /\bdrop\s+(table|database)/i,
127
+ /\bformat\b/,
128
+ ];
129
+ return destructivePatterns.some((p) => p.test(cmd))
130
+ ? 'destructive'
131
+ : 'normal';
132
+ }
133
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};