kanon-cli 0.1.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.
- package/LICENSE +21 -0
- package/bin/kanon.js +266 -0
- package/package.json +34 -0
- package/src/commands/attachment.js +98 -0
- package/src/commands/boards.js +39 -0
- package/src/commands/card.js +260 -0
- package/src/commands/cards.js +79 -0
- package/src/commands/checklist.js +129 -0
- package/src/commands/dashboard.js +24 -0
- package/src/commands/init.js +89 -0
- package/src/commands/label.js +61 -0
- package/src/commands/list.js +78 -0
- package/src/commands/login.js +91 -0
- package/src/commands/watch.js +224 -0
- package/src/dashboard/dist/assets/index-Dcbpx-Xz.js +186 -0
- package/src/dashboard/dist/assets/index-DhFfv70f.css +1 -0
- package/src/dashboard/dist/index.html +13 -0
- package/src/dashboard/dist/kanon.png +0 -0
- package/src/dashboard/package.json +26 -0
- package/src/dashboard/server/agent.js +201 -0
- package/src/dashboard/server/index.js +85 -0
- package/src/dashboard/server/proxy.js +54 -0
- package/src/dashboard/server/settings.js +236 -0
- package/src/lib/admin.js +330 -0
- package/src/lib/api.js +225 -0
- package/src/lib/claude.js +161 -0
- package/src/lib/config.js +112 -0
- package/src/lib/pipeline.js +133 -0
- package/src/lib/websocket.js +194 -0
- package/src/prompts/templates.js +127 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
// Resolve the bin/ directory so `kanon` is in PATH for spawned Claude sessions
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const kanonBinDir = path.resolve(__dirname, '../../bin');
|
|
9
|
+
|
|
10
|
+
/** Active Claude processes indexed by cardId */
|
|
11
|
+
const activeWorkers = new Map();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Spawn a Claude Code process for a card.
|
|
15
|
+
* Returns a WorkerInfo object tracked by the agent controller.
|
|
16
|
+
*/
|
|
17
|
+
export function spawnClaude(cardId, prompt, config = {}) {
|
|
18
|
+
if (activeWorkers.has(cardId)) {
|
|
19
|
+
console.log(chalk.yellow(`Worker already running for card ${cardId}, skipping.`));
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const claudeCmd = config.command || 'claude';
|
|
24
|
+
const defaultArgs = '--print\n--output-format stream-json\n--verbose\n--dangerously-skip-permissions';
|
|
25
|
+
const argsStr = config.args || defaultArgs;
|
|
26
|
+
// Parse args: split by lines, strip comments, then split each line by whitespace
|
|
27
|
+
const args = [
|
|
28
|
+
...argsStr
|
|
29
|
+
.split('\n')
|
|
30
|
+
.map(line => line.replace(/#.*$/, '').trim()) // strip comments
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.flatMap(line => line.split(/\s+/)), // split remaining into tokens
|
|
33
|
+
prompt,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const projectDir = config.project_dir || process.cwd();
|
|
37
|
+
|
|
38
|
+
const worker = {
|
|
39
|
+
cardId,
|
|
40
|
+
startedAt: Date.now(),
|
|
41
|
+
lastOutput: Date.now(),
|
|
42
|
+
output: [],
|
|
43
|
+
exitCode: null,
|
|
44
|
+
process: null,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const proc = spawn(claudeCmd, args, {
|
|
48
|
+
cwd: projectDir,
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
shell: process.platform === 'win32',
|
|
51
|
+
env: { ...process.env, PATH: `${kanonBinDir}:${process.env.PATH}` },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
worker.process = proc;
|
|
55
|
+
|
|
56
|
+
// Buffer for incomplete JSON lines from stream-json output
|
|
57
|
+
let stdoutBuf = '';
|
|
58
|
+
|
|
59
|
+
proc.stdout.on('data', (data) => {
|
|
60
|
+
stdoutBuf += data.toString();
|
|
61
|
+
const lines = stdoutBuf.split('\n');
|
|
62
|
+
// Keep the last (possibly incomplete) chunk in the buffer
|
|
63
|
+
stdoutBuf = lines.pop() || '';
|
|
64
|
+
|
|
65
|
+
for (const raw of lines) {
|
|
66
|
+
const trimmed = raw.trim();
|
|
67
|
+
if (!trimmed) continue;
|
|
68
|
+
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(trimmed);
|
|
72
|
+
} catch {
|
|
73
|
+
// Not valid JSON – store raw line as-is
|
|
74
|
+
worker.lastOutput = Date.now();
|
|
75
|
+
worker.output.push(trimmed);
|
|
76
|
+
if (worker.output.length > 100) worker.output.shift();
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
worker.lastOutput = Date.now();
|
|
81
|
+
|
|
82
|
+
// Extract human-readable summary from stream-json messages
|
|
83
|
+
if (parsed.type === 'assistant' && parsed.message?.content) {
|
|
84
|
+
for (const block of parsed.message.content) {
|
|
85
|
+
if (block.type === 'text' && block.text) {
|
|
86
|
+
worker.output.push(block.text);
|
|
87
|
+
} else if (block.type === 'tool_use') {
|
|
88
|
+
const name = block.name || 'unknown';
|
|
89
|
+
const inputPreview = block.input
|
|
90
|
+
? JSON.stringify(block.input).substring(0, 120)
|
|
91
|
+
: '';
|
|
92
|
+
worker.output.push(`[Tool: ${name}] ${inputPreview}`);
|
|
93
|
+
}
|
|
94
|
+
if (worker.output.length > 100) worker.output.shift();
|
|
95
|
+
}
|
|
96
|
+
} else if (parsed.type === 'result' && parsed.result) {
|
|
97
|
+
worker.output.push(`[Result] ${parsed.result.substring(0, 200)}`);
|
|
98
|
+
if (worker.output.length > 100) worker.output.shift();
|
|
99
|
+
} else if (parsed.type === 'error') {
|
|
100
|
+
worker.output.push(`[Error] ${parsed.error?.message || JSON.stringify(parsed)}`);
|
|
101
|
+
if (worker.output.length > 100) worker.output.shift();
|
|
102
|
+
}
|
|
103
|
+
// Silently skip other message types (system, etc.)
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
proc.stderr.on('data', (data) => {
|
|
108
|
+
const line = data.toString();
|
|
109
|
+
worker.lastOutput = Date.now();
|
|
110
|
+
worker.output.push(`[stderr] ${line}`);
|
|
111
|
+
if (worker.output.length > 100) worker.output.shift();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
proc.on('close', (code) => {
|
|
115
|
+
worker.exitCode = code;
|
|
116
|
+
activeWorkers.delete(cardId);
|
|
117
|
+
console.log(chalk.dim(`Worker for card ${cardId} exited with code ${code}`));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
proc.on('error', (err) => {
|
|
121
|
+
console.error(chalk.red(`Failed to spawn Claude for card ${cardId}:`), err.message);
|
|
122
|
+
activeWorkers.delete(cardId);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
activeWorkers.set(cardId, worker);
|
|
126
|
+
return worker;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Kill a running worker for a card.
|
|
131
|
+
*/
|
|
132
|
+
export function killWorker(cardId) {
|
|
133
|
+
const worker = activeWorkers.get(cardId);
|
|
134
|
+
if (worker?.process) {
|
|
135
|
+
worker.process.kill('SIGTERM');
|
|
136
|
+
// Force kill after 5s
|
|
137
|
+
setTimeout(() => {
|
|
138
|
+
if (worker.process && !worker.process.killed) {
|
|
139
|
+
worker.process.kill('SIGKILL');
|
|
140
|
+
}
|
|
141
|
+
}, 5000);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get all active workers.
|
|
149
|
+
*/
|
|
150
|
+
export function getActiveWorkers() {
|
|
151
|
+
return activeWorkers;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get worker count.
|
|
156
|
+
*/
|
|
157
|
+
export function getWorkerCount() {
|
|
158
|
+
return activeWorkers.size;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export default { spawnClaude, killWorker, getActiveWorkers, getWorkerCount };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
|
|
6
|
+
const GLOBAL_DIR = path.join(os.homedir(), '.kanon');
|
|
7
|
+
const GLOBAL_CONFIG_PATH = path.join(GLOBAL_DIR, 'config.yaml');
|
|
8
|
+
const PROJECT_CONFIG_NAME = 'kanon.config.yaml';
|
|
9
|
+
|
|
10
|
+
function ensureDir(dir) {
|
|
11
|
+
if (!fs.existsSync(dir)) {
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- Global config (~/.kanon/config.yaml) ---
|
|
17
|
+
|
|
18
|
+
export function loadGlobalConfig() {
|
|
19
|
+
if (!fs.existsSync(GLOBAL_CONFIG_PATH)) return {};
|
|
20
|
+
const raw = fs.readFileSync(GLOBAL_CONFIG_PATH, 'utf8');
|
|
21
|
+
return YAML.parse(raw) || {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function saveGlobalConfig(obj) {
|
|
25
|
+
ensureDir(GLOBAL_DIR);
|
|
26
|
+
fs.writeFileSync(GLOBAL_CONFIG_PATH, YAML.stringify(obj), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getToken() {
|
|
30
|
+
return loadGlobalConfig().token || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getUserId() {
|
|
34
|
+
return loadGlobalConfig().user_id || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getServerUrl() {
|
|
38
|
+
const project = loadProjectConfig();
|
|
39
|
+
if (project?.server_url) return project.server_url;
|
|
40
|
+
const global = loadGlobalConfig();
|
|
41
|
+
return global.server_url || 'https://kanones.com';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Project config (./kanon.config.yaml) ---
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Detect old single-board format (top-level board_id) and convert to boards array.
|
|
48
|
+
* Returns migrated config in-memory (does not save to disk).
|
|
49
|
+
*/
|
|
50
|
+
export function migrateConfig(config) {
|
|
51
|
+
if (!config) return config;
|
|
52
|
+
// Already migrated
|
|
53
|
+
if (config.boards) return config;
|
|
54
|
+
// No board_id means nothing to migrate
|
|
55
|
+
if (!config.board_id) return config;
|
|
56
|
+
|
|
57
|
+
const boardIds = Array.isArray(config.board_id) ? config.board_id : [config.board_id];
|
|
58
|
+
const boards = boardIds.map((id, i) => ({
|
|
59
|
+
id,
|
|
60
|
+
name: `Board ${i + 1}`,
|
|
61
|
+
active: true,
|
|
62
|
+
bundle_queue: config.admin?.bundle_queue ?? true,
|
|
63
|
+
claude: { ...config.claude },
|
|
64
|
+
watch: { ...config.watch },
|
|
65
|
+
rules: config.rules ? JSON.parse(JSON.stringify(config.rules)) : {},
|
|
66
|
+
prompts: config.prompts ? JSON.parse(JSON.stringify(config.prompts)) : {},
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
const { board_id, claude, watch, rules, prompts, ...rest } = config;
|
|
70
|
+
return { ...rest, boards };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function loadProjectConfig() {
|
|
74
|
+
const configPath = path.join(process.cwd(), PROJECT_CONFIG_NAME);
|
|
75
|
+
if (!fs.existsSync(configPath)) return null;
|
|
76
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
77
|
+
const config = YAML.parse(raw) || {};
|
|
78
|
+
return migrateConfig(config);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function saveProjectConfig(obj) {
|
|
82
|
+
const configPath = path.join(process.cwd(), PROJECT_CONFIG_NAME);
|
|
83
|
+
fs.writeFileSync(configPath, YAML.stringify(obj), 'utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get IDs of all active boards from config.
|
|
88
|
+
*/
|
|
89
|
+
export function getActiveBoardIds(config) {
|
|
90
|
+
if (!config?.boards) return [];
|
|
91
|
+
return config.boards.filter(b => b.active).map(b => b.id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get full config for a specific board.
|
|
96
|
+
*/
|
|
97
|
+
export function getBoardConfig(config, boardId) {
|
|
98
|
+
if (!config?.boards) return null;
|
|
99
|
+
return config.boards.find(b => b.id === boardId) || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Status file (~/.kanon/status.json) ---
|
|
103
|
+
|
|
104
|
+
export function getStatusPath() {
|
|
105
|
+
return path.join(GLOBAL_DIR, 'status.json');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getAgentLockPath() {
|
|
109
|
+
return path.join(GLOBAL_DIR, 'agent.lock');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export { GLOBAL_DIR, GLOBAL_CONFIG_PATH, PROJECT_CONFIG_NAME };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Event trigger logic for the Kanon watch daemon.
|
|
2
|
+
// Uses the `rules` config structure: rules.labels[] and rules.events.{}
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Determine if an event should trigger the agent.
|
|
6
|
+
* Returns { trigger, extraPrompt, extraArgs }.
|
|
7
|
+
*/
|
|
8
|
+
export function shouldTrigger(event, config, ownUserId) {
|
|
9
|
+
const { eventType, eventData, userId } = event;
|
|
10
|
+
|
|
11
|
+
// Never trigger on own events (unless explicitly disabled)
|
|
12
|
+
if (config?.watch?.ignore_own_events !== false && userId === ownUserId) {
|
|
13
|
+
return { trigger: false, extraPrompt: '', extraArgs: '' };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const rules = config?.rules || {};
|
|
17
|
+
const labels = rules.labels || [];
|
|
18
|
+
const events = rules.events || {};
|
|
19
|
+
|
|
20
|
+
// --- Label triggers (only labels with trigger: true or trigger undefined) ---
|
|
21
|
+
if (eventType === 'label_added') {
|
|
22
|
+
const labelName = eventData?.label_name || '';
|
|
23
|
+
const match = labels.find(l =>
|
|
24
|
+
l.enabled !== false &&
|
|
25
|
+
l.trigger !== false &&
|
|
26
|
+
labelName.toLowerCase().includes(l.name.toLowerCase())
|
|
27
|
+
);
|
|
28
|
+
if (match) {
|
|
29
|
+
return { trigger: true, extraPrompt: match.prompt || '', extraArgs: match.args || '' };
|
|
30
|
+
}
|
|
31
|
+
return { trigger: false, extraPrompt: '', extraArgs: '' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- List triggers (card_moved) ---
|
|
35
|
+
if (eventType === 'card_moved') {
|
|
36
|
+
const listRules = rules.lists || [];
|
|
37
|
+
const listName = eventData?.to_list || eventData?.list_name || '';
|
|
38
|
+
const match = listRules.find(l =>
|
|
39
|
+
l.enabled !== false &&
|
|
40
|
+
l.trigger !== false &&
|
|
41
|
+
listName.toLowerCase().includes(l.name.toLowerCase())
|
|
42
|
+
);
|
|
43
|
+
if (match) {
|
|
44
|
+
return { trigger: true, extraPrompt: match.prompt || '', extraArgs: match.args || '' };
|
|
45
|
+
}
|
|
46
|
+
return { trigger: false, extraPrompt: '', extraArgs: '' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Event triggers ---
|
|
50
|
+
|
|
51
|
+
if ((eventType === 'card_assigned' || eventType === 'member_added') && (eventData?.assignee_id === ownUserId || eventData?.member_id === ownUserId)) {
|
|
52
|
+
const rule = events.agent_assigned;
|
|
53
|
+
const enabled = rule?.enabled ?? true;
|
|
54
|
+
if (enabled) {
|
|
55
|
+
return {
|
|
56
|
+
trigger: true,
|
|
57
|
+
extraPrompt: rule?.prompt || 'You were just assigned to this card. Read the content carefully. If there is a task, execute it. If you need more information, add a comment with your questions.',
|
|
58
|
+
extraArgs: rule?.args || '',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { trigger: false, extraPrompt: '', extraArgs: '' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (eventType === 'comment_added') {
|
|
65
|
+
const rule = events.comment_added;
|
|
66
|
+
if (rule?.enabled) {
|
|
67
|
+
// scope: 'assigned' means only trigger for cards assigned to the agent
|
|
68
|
+
// The actual assignee check happens in watch.js after fetching the card
|
|
69
|
+
return {
|
|
70
|
+
trigger: true,
|
|
71
|
+
scope: rule.scope || 'assigned',
|
|
72
|
+
extraPrompt: rule?.prompt || 'A new comment was added. Read it and determine if any action is needed.',
|
|
73
|
+
extraArgs: rule?.args || '',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { trigger: false, extraPrompt: '', extraArgs: '' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (eventType === 'due_date_changed') {
|
|
80
|
+
const rule = events.due_date_changed;
|
|
81
|
+
if (rule?.enabled) {
|
|
82
|
+
return {
|
|
83
|
+
trigger: true,
|
|
84
|
+
scope: rule.scope || 'assigned',
|
|
85
|
+
extraPrompt: rule?.prompt || 'The due date on this card was changed. Review the card and adjust your priorities if needed.',
|
|
86
|
+
extraArgs: rule?.args || '',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { trigger: false, extraPrompt: '', extraArgs: '' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { trigger: false, extraPrompt: '', extraArgs: '' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Collect extra prompts and args from context-only labels and lists on a card.
|
|
97
|
+
* These have trigger: false but provide additional instructions when present.
|
|
98
|
+
*/
|
|
99
|
+
export function getContextFromLabels(card, config) {
|
|
100
|
+
const labels = config?.rules?.labels || [];
|
|
101
|
+
const listRules = config?.rules?.lists || [];
|
|
102
|
+
const cardLabels = card.labels || [];
|
|
103
|
+
let extraPrompt = '';
|
|
104
|
+
let extraArgs = '';
|
|
105
|
+
|
|
106
|
+
// Context from labels
|
|
107
|
+
for (const rule of labels) {
|
|
108
|
+
if (rule.enabled === false || rule.trigger !== false) continue;
|
|
109
|
+
const match = cardLabels.some(cl =>
|
|
110
|
+
cl.name.toLowerCase().includes(rule.name.toLowerCase())
|
|
111
|
+
);
|
|
112
|
+
if (match) {
|
|
113
|
+
if (rule.prompt) extraPrompt += (extraPrompt ? '\n' : '') + rule.prompt;
|
|
114
|
+
if (rule.args) extraArgs += (extraArgs ? ' ' : '') + rule.args;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Context from lists (check which list the card is in by list_id)
|
|
119
|
+
// Note: card.list_id is the ID, but we need to match by name.
|
|
120
|
+
// The card object from the API includes list info if available, otherwise
|
|
121
|
+
// we match on list_name if provided in the card data.
|
|
122
|
+
for (const rule of listRules) {
|
|
123
|
+
if (rule.enabled === false || rule.trigger !== false) continue;
|
|
124
|
+
// Match by list_name if available on card, or by list title from board context
|
|
125
|
+
const cardListName = card.list_name || card.list_title || '';
|
|
126
|
+
if (cardListName && cardListName.toLowerCase().includes(rule.name.toLowerCase())) {
|
|
127
|
+
if (rule.prompt) extraPrompt += (extraPrompt ? '\n' : '') + rule.prompt;
|
|
128
|
+
if (rule.args) extraArgs += (extraArgs ? ' ' : '') + rule.args;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { extraPrompt, extraArgs };
|
|
133
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export class KanonWebSocket {
|
|
5
|
+
constructor(serverUrl, token, userInfo = {}) {
|
|
6
|
+
this.serverUrl = serverUrl;
|
|
7
|
+
this.token = token;
|
|
8
|
+
this.userInfo = userInfo;
|
|
9
|
+
this.ws = null;
|
|
10
|
+
this.isConnected = false;
|
|
11
|
+
this.isAuthenticated = false;
|
|
12
|
+
this.reconnectAttempts = 0;
|
|
13
|
+
this.maxReconnectAttempts = 50; // Long-running daemon
|
|
14
|
+
this.reconnectDelay = 1000;
|
|
15
|
+
this.listeners = new Map();
|
|
16
|
+
this.boardIds = new Set();
|
|
17
|
+
this.pingInterval = null;
|
|
18
|
+
this._closing = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
connect() {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
24
|
+
resolve();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Convert https:// to wss://, http:// to ws://
|
|
29
|
+
const wsUrl = this.serverUrl
|
|
30
|
+
.replace(/^https:/, 'wss:')
|
|
31
|
+
.replace(/^http:/, 'ws:') + '/ws';
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
this.ws = new WebSocket(wsUrl);
|
|
35
|
+
|
|
36
|
+
this.ws.on('open', () => {
|
|
37
|
+
this.isConnected = true;
|
|
38
|
+
this.reconnectAttempts = 0;
|
|
39
|
+
// Authenticate
|
|
40
|
+
this._send({ type: 'auth', token: this.token });
|
|
41
|
+
this._startPing();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.ws.on('message', (raw) => {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(raw.toString());
|
|
47
|
+
this._handleMessage(data, resolve);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(chalk.red('WS message parse error:'), err.message);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.ws.on('close', () => {
|
|
54
|
+
this.isConnected = false;
|
|
55
|
+
this.isAuthenticated = false;
|
|
56
|
+
this._stopPing();
|
|
57
|
+
if (!this._closing) {
|
|
58
|
+
this._attemptReconnect();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.ws.on('error', (err) => {
|
|
63
|
+
if (!this.isConnected) {
|
|
64
|
+
reject(err);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
reject(err);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
joinBoard(boardId) {
|
|
74
|
+
this.boardIds.add(boardId);
|
|
75
|
+
if (this.isAuthenticated) {
|
|
76
|
+
this._send({
|
|
77
|
+
type: 'join_board',
|
|
78
|
+
boardId,
|
|
79
|
+
user: this.userInfo,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
leaveBoard(boardId) {
|
|
85
|
+
this.boardIds.delete(boardId);
|
|
86
|
+
if (this.isAuthenticated) {
|
|
87
|
+
this._send({ type: 'leave_board', boardId });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
on(eventType, callback) {
|
|
92
|
+
if (!this.listeners.has(eventType)) {
|
|
93
|
+
this.listeners.set(eventType, new Set());
|
|
94
|
+
}
|
|
95
|
+
this.listeners.get(eventType).add(callback);
|
|
96
|
+
return () => this.listeners.get(eventType)?.delete(callback);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
disconnect() {
|
|
100
|
+
this._closing = true;
|
|
101
|
+
this._stopPing();
|
|
102
|
+
if (this.ws) {
|
|
103
|
+
this.ws.close();
|
|
104
|
+
this.ws = null;
|
|
105
|
+
}
|
|
106
|
+
this.isConnected = false;
|
|
107
|
+
this.isAuthenticated = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Internal ---
|
|
111
|
+
|
|
112
|
+
_send(data) {
|
|
113
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
114
|
+
this.ws.send(JSON.stringify(data));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_handleMessage(data, onAuthResolve) {
|
|
119
|
+
const { type } = data;
|
|
120
|
+
|
|
121
|
+
if (type === 'auth_success') {
|
|
122
|
+
this.isAuthenticated = true;
|
|
123
|
+
// Re-join all boards after auth
|
|
124
|
+
for (const boardId of this.boardIds) {
|
|
125
|
+
this._send({
|
|
126
|
+
type: 'join_board',
|
|
127
|
+
boardId,
|
|
128
|
+
user: this.userInfo,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (onAuthResolve) onAuthResolve();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (type === 'auth_error') {
|
|
136
|
+
console.error(chalk.red('WebSocket auth failed:'), data.error);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (type === 'pong') return;
|
|
141
|
+
|
|
142
|
+
// Notify type-specific listeners
|
|
143
|
+
const typeListeners = this.listeners.get(type);
|
|
144
|
+
if (typeListeners) {
|
|
145
|
+
typeListeners.forEach(cb => cb(data));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Notify 'all' listeners
|
|
149
|
+
const allListeners = this.listeners.get('all');
|
|
150
|
+
if (allListeners) {
|
|
151
|
+
allListeners.forEach(cb => cb(data));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_attemptReconnect() {
|
|
156
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
157
|
+
console.log(chalk.red('Max reconnection attempts reached.'));
|
|
158
|
+
this._emit('max_reconnect');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.reconnectAttempts++;
|
|
163
|
+
const delay = this.reconnectDelay * Math.min(2 ** (this.reconnectAttempts - 1), 16);
|
|
164
|
+
|
|
165
|
+
console.log(chalk.yellow(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})...`));
|
|
166
|
+
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
if (!this._closing) {
|
|
169
|
+
this.connect().catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
}, delay);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_emit(type, data) {
|
|
175
|
+
const listeners = this.listeners.get(type);
|
|
176
|
+
if (listeners) listeners.forEach(cb => cb(data));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_startPing() {
|
|
180
|
+
this._stopPing();
|
|
181
|
+
this.pingInterval = setInterval(() => {
|
|
182
|
+
this._send({ type: 'ping' });
|
|
183
|
+
}, 25000); // Slightly under server's 30s heartbeat
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_stopPing() {
|
|
187
|
+
if (this.pingInterval) {
|
|
188
|
+
clearInterval(this.pingInterval);
|
|
189
|
+
this.pingInterval = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export default KanonWebSocket;
|