ticlawk-connector 0.1.0-alpha.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 ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 ticlawk
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # ticlawk-connector
2
+
3
+ Local connector for pairing Claude Code and OpenClaw agents with ticlawk.
4
+
5
+ Status: alpha
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx ticlawk-connector start
11
+ ```
12
+
13
+ The connector reads config from `~/.ticlawk/.config`.
14
+
15
+ ## Commands
16
+
17
+ ```bash
18
+ npx ticlawk-connector start
19
+ npx ticlawk-connector health
20
+ npx ticlawk-connector config-path
21
+ npx ticlawk-connector pair --code 123456 --session-id <claude-session-id>
22
+ npx ticlawk-connector pair --code 123456 --type openclaw --channel-id myskills --name myskills
23
+ ```
24
+
25
+ ## Config
26
+
27
+ Example `~/.ticlawk/.config`:
28
+
29
+ ```bash
30
+ TICLAWK_API_KEY=tk_xxxx
31
+ TICLAWK_API_URL=https://ticlawk.com
32
+ GATEWAY_HOST=127.0.0.1
33
+ GATEWAY_PORT=18789
34
+ CC_COMPACT_THRESHOLD=500000
35
+ FEED_RELAY_PORT=3002
36
+ ```
37
+
38
+ `TICLAWK_API_KEY` is written automatically after successful pairing.
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { request } from 'node:http';
4
+ import { startConnector, TICLAWK_CONFIG_PATH } from '../ticlawk-connector.mjs';
5
+
6
+ function printUsage() {
7
+ console.log(`ticlawk-connector
8
+
9
+ Usage:
10
+ ticlawk-connector start
11
+ ticlawk-connector health
12
+ ticlawk-connector pair --code <code> --session-id <id>
13
+ ticlawk-connector pair --code <code> --type openclaw --channel-id <id> [--name <name>]
14
+ ticlawk-connector config-path
15
+ `);
16
+ }
17
+
18
+ function parseArgs(argv) {
19
+ const args = { _: [] };
20
+ for (let i = 0; i < argv.length; i += 1) {
21
+ const arg = argv[i];
22
+ if (arg.startsWith('--')) {
23
+ const key = arg.slice(2);
24
+ const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
25
+ args[key] = value;
26
+ } else {
27
+ args._.push(arg);
28
+ }
29
+ }
30
+ return args;
31
+ }
32
+
33
+ function postLocal(path, body) {
34
+ return new Promise((resolve, reject) => {
35
+ const req = request(
36
+ {
37
+ host: '127.0.0.1',
38
+ port: 3002,
39
+ path,
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ },
43
+ (res) => {
44
+ let data = '';
45
+ res.on('data', (chunk) => { data += chunk; });
46
+ res.on('end', () => {
47
+ try {
48
+ resolve({
49
+ statusCode: res.statusCode || 0,
50
+ body: data ? JSON.parse(data) : {},
51
+ });
52
+ } catch (err) {
53
+ reject(err);
54
+ }
55
+ });
56
+ }
57
+ );
58
+ req.on('error', reject);
59
+ req.write(JSON.stringify(body));
60
+ req.end();
61
+ });
62
+ }
63
+
64
+ function getLocal(path) {
65
+ return new Promise((resolve, reject) => {
66
+ const req = request(
67
+ { host: '127.0.0.1', port: 3002, path, method: 'GET' },
68
+ (res) => {
69
+ let data = '';
70
+ res.on('data', (chunk) => { data += chunk; });
71
+ res.on('end', () => {
72
+ try {
73
+ resolve({
74
+ statusCode: res.statusCode || 0,
75
+ body: data ? JSON.parse(data) : {},
76
+ });
77
+ } catch (err) {
78
+ reject(err);
79
+ }
80
+ });
81
+ }
82
+ );
83
+ req.on('error', reject);
84
+ req.end();
85
+ });
86
+ }
87
+
88
+ async function main() {
89
+ const args = parseArgs(process.argv.slice(2));
90
+ const command = args._[0] || 'start';
91
+
92
+ if (command === 'start') {
93
+ await startConnector();
94
+ return;
95
+ }
96
+
97
+ if (command === 'config-path') {
98
+ console.log(TICLAWK_CONFIG_PATH);
99
+ return;
100
+ }
101
+
102
+ if (command === 'health') {
103
+ const res = await getLocal('/health');
104
+ console.log(JSON.stringify(res.body, null, 2));
105
+ process.exitCode = res.statusCode >= 400 ? 1 : 0;
106
+ return;
107
+ }
108
+
109
+ if (command === 'pair') {
110
+ if (!args.code) {
111
+ console.error('--code is required');
112
+ process.exit(1);
113
+ }
114
+
115
+ const serviceType = args.type === 'openclaw' ? 'openclaw' : 'claude_code';
116
+ const payload = serviceType === 'openclaw'
117
+ ? {
118
+ code: args.code,
119
+ serviceType,
120
+ channelId: args['channel-id'],
121
+ name: args.name || args['channel-id'],
122
+ }
123
+ : {
124
+ code: args.code,
125
+ sessionId: args['session-id'],
126
+ };
127
+
128
+ if (serviceType === 'openclaw' && !payload.channelId) {
129
+ console.error('--channel-id is required for openclaw pairing');
130
+ process.exit(1);
131
+ }
132
+ if (serviceType === 'claude_code' && !payload.sessionId) {
133
+ console.error('--session-id is required for claude_code pairing');
134
+ process.exit(1);
135
+ }
136
+
137
+ const res = await postLocal('/pair', payload);
138
+ console.log(JSON.stringify(res.body, null, 2));
139
+ process.exitCode = res.statusCode >= 400 ? 1 : 0;
140
+ return;
141
+ }
142
+
143
+ printUsage();
144
+ process.exit(1);
145
+ }
146
+
147
+ main().catch((err) => {
148
+ console.error(err.message || String(err));
149
+ process.exit(1);
150
+ });
package/cc-watcher.mjs ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * cc-watcher.mjs
3
+ *
4
+ * Watches Claude Code sessions for new output and exposes transcript helpers.
5
+ * Ticlawk connector uses this to discover sessions, sync transcript history,
6
+ * and detect new transcript entries.
7
+ */
8
+
9
+ import { readFileSync, statSync, watch } from 'node:fs';
10
+ import { readdir } from 'node:fs/promises';
11
+ import { join, basename } from 'node:path';
12
+
13
+ // ── Discover Sessions ───────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Scan a .claude/projects/ directory for session .jsonl files.
17
+ * Returns an array of { sessionId, project, path, projectDir }
18
+ */
19
+ export async function discoverSessions(claudeProjectsDir) {
20
+ const sessions = [];
21
+
22
+ let projectDirs;
23
+ try {
24
+ projectDirs = await readdir(claudeProjectsDir);
25
+ } catch {
26
+ return sessions;
27
+ }
28
+
29
+ for (const projName of projectDirs) {
30
+ const projPath = join(claudeProjectsDir, projName);
31
+
32
+ let files;
33
+ try {
34
+ files = await readdir(projPath);
35
+ } catch {
36
+ continue;
37
+ }
38
+
39
+ // Collect all .jsonl sessions in this project, pick the most recent one
40
+ let best = null;
41
+ let bestMtime = 0;
42
+
43
+ for (const file of files) {
44
+ if (!file.endsWith('.jsonl')) continue;
45
+ if (file.includes('subagent')) continue;
46
+
47
+ const filePath = join(projPath, file);
48
+ let mtime = 0;
49
+ try {
50
+ mtime = statSync(filePath).mtimeMs;
51
+ } catch { continue; }
52
+
53
+ if (mtime > bestMtime) {
54
+ bestMtime = mtime;
55
+ best = { file, filePath };
56
+ }
57
+ }
58
+
59
+ if (!best) continue;
60
+
61
+ const sessionId = basename(best.file, '.jsonl');
62
+
63
+ // Read cwd directly from transcript (first user entry has it)
64
+ let projectDir = null;
65
+ try {
66
+ const content = readFileSync(best.filePath, 'utf8');
67
+ for (const line of content.split('\n')) {
68
+ if (!line.trim()) continue;
69
+ const entry = JSON.parse(line);
70
+ if (entry.cwd) {
71
+ projectDir = entry.cwd;
72
+ break;
73
+ }
74
+ }
75
+ } catch { /* ignore */ }
76
+
77
+ if (!projectDir) {
78
+ // Fallback: naive decode
79
+ projectDir = '/' + projName.replace(/^-/, '').replace(/-/g, '/');
80
+ }
81
+
82
+ sessions.push({
83
+ sessionId,
84
+ project: projName,
85
+ path: best.filePath,
86
+ projectDir,
87
+ });
88
+ }
89
+
90
+ return sessions;
91
+ }
92
+
93
+ // ── Watch Transcript ────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Watch a transcript .jsonl file for new assistant messages.
97
+ * Calls onMessage(msg) when a new assistant message is detected.
98
+ * msg = { type: 'assistant', text: string, raw: object }
99
+ *
100
+ * Returns a cleanup function to stop watching.
101
+ */
102
+ export function watchTranscript(transcriptPath, onMessage) {
103
+ let lastSize = 0;
104
+
105
+ try {
106
+ lastSize = statSync(transcriptPath).size;
107
+ } catch {
108
+ // File might not exist yet
109
+ }
110
+
111
+ const checkForNew = () => {
112
+ let currentSize;
113
+ try {
114
+ currentSize = statSync(transcriptPath).size;
115
+ } catch {
116
+ return;
117
+ }
118
+
119
+ if (currentSize <= lastSize) return;
120
+
121
+ // Read only the new bytes (use Buffer to slice by byte offset, not char offset)
122
+ const buf = readFileSync(transcriptPath);
123
+ const newContent = buf.subarray(lastSize).toString('utf8');
124
+ lastSize = currentSize;
125
+
126
+ // Parse new lines
127
+ const lines = newContent.split('\n').filter(l => l.trim());
128
+ for (const line of lines) {
129
+ let entry;
130
+ try {
131
+ entry = JSON.parse(line);
132
+ } catch {
133
+ continue;
134
+ }
135
+
136
+ if (entry.type !== 'assistant' && entry.type !== 'user') continue;
137
+
138
+ // Extract text and tool info from the message content
139
+ const content = entry.message?.content;
140
+ let text = '';
141
+ let toolNames = [];
142
+ if (typeof content === 'string') {
143
+ text = content;
144
+ } else if (Array.isArray(content)) {
145
+ text = content
146
+ .filter(c => c.type === 'text')
147
+ .map(c => c.text)
148
+ .join('\n');
149
+ toolNames = content
150
+ .filter(c => c.type === 'tool_use')
151
+ .map(c => c.name);
152
+ }
153
+
154
+ const stopReason = entry.message?.stop_reason;
155
+
156
+ // Emit tool_use events (agent is processing)
157
+ if (entry.type === 'assistant' && toolNames.length > 0 && stopReason === 'tool_use') {
158
+ onMessage({ type: 'tool_use', text: '', toolNames, raw: entry });
159
+ continue; // Don't also emit as text message
160
+ }
161
+
162
+ if (!text) continue;
163
+
164
+ onMessage({ type: entry.type, text, raw: entry });
165
+ }
166
+ };
167
+
168
+ // Use fs.watch for instant detection, poll as fallback
169
+ let watcher;
170
+ try {
171
+ watcher = watch(transcriptPath, { persistent: false }, () => {
172
+ checkForNew();
173
+ });
174
+ } catch {
175
+ // fs.watch not available, fall back to polling
176
+ }
177
+
178
+ const interval = setInterval(checkForNew, 2000);
179
+
180
+ return () => {
181
+ clearInterval(interval);
182
+ watcher?.close();
183
+ };
184
+ }
185
+
186
+ // ── Parse Transcript ────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Extract text from a transcript entry's message content.
190
+ */
191
+ function extractText(entry) {
192
+ const content = entry.message?.content;
193
+ if (typeof content === 'string') return content;
194
+ if (Array.isArray(content)) {
195
+ return content.filter(c => c.type === 'text').map(c => c.text).join('\n');
196
+ }
197
+ return '';
198
+ }
199
+
200
+ /**
201
+ * Read transcript and return all user/assistant messages.
202
+ * Each entry: { type: 'user'|'assistant', text: string, timestamp: number }
203
+ */
204
+ export function readTranscript(transcriptPath) {
205
+ let content;
206
+ try {
207
+ content = readFileSync(transcriptPath, 'utf8');
208
+ } catch {
209
+ return [];
210
+ }
211
+
212
+ const messages = [];
213
+ for (const line of content.split('\n')) {
214
+ if (!line.trim()) continue;
215
+ let entry;
216
+ try { entry = JSON.parse(line); } catch { continue; }
217
+
218
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
219
+
220
+ const text = extractText(entry);
221
+ if (!text) continue;
222
+
223
+ // Use message timestamp if available, otherwise approximate
224
+ const timestamp = entry.message?.timestamp || entry.timestamp || 0;
225
+ messages.push({ type: entry.type, text, timestamp });
226
+ }
227
+
228
+ return messages;
229
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "ticlawk-connector",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Local connector for pairing Claude Code and OpenClaw agents with ticlawk.",
5
+ "type": "module",
6
+ "main": "ticlawk-connector.mjs",
7
+ "bin": {
8
+ "ticlawk-connector": "bin/ticlawk-connector.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "cc-watcher.mjs",
13
+ "ticlawk-api.mjs",
14
+ "ticlawk-connector.mjs",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "scripts": {
25
+ "start": "node ./bin/ticlawk-connector.mjs start",
26
+ "connector": "node ./bin/ticlawk-connector.mjs start",
27
+ "health": "node ./bin/ticlawk-connector.mjs health",
28
+ "dev": "echo 'Configure ~/.ticlawk/.config, then: npm run connector'",
29
+ "generate:setup-docs": "node scripts/generate-setup-docs.mjs"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/darthjaja6/ticlawk.git"
34
+ },
35
+ "keywords": [
36
+ "ticlawk",
37
+ "connector",
38
+ "claude-code",
39
+ "openclaw",
40
+ "agent"
41
+ ],
42
+ "author": "ticlawk",
43
+ "license": "ISC",
44
+ "bugs": {
45
+ "url": "https://github.com/darthjaja6/ticlawk/issues"
46
+ },
47
+ "homepage": "https://github.com/darthjaja6/ticlawk#readme",
48
+ "dependencies": {
49
+ "@supabase/supabase-js": "^2.99.2",
50
+ "openclaw": "^2026.3.13",
51
+ "ws": "^8.19.0"
52
+ }
53
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * ticlawk-api.mjs
3
+ *
4
+ * HTTP API client for ticlawk-connector.
5
+ * All operations go through ticlawk.com/api/* (or TICLAWK_API_URL).
6
+ * No direct Supabase access — only needs a tk_ API key.
7
+ */
8
+
9
+ function getApiUrl() { return process.env.TICLAWK_API_URL || 'https://ticlawk.com'; }
10
+ function getApiKey() { return process.env.TICLAWK_API_KEY || ''; }
11
+
12
+ async function apiFetch(path, opts = {}) {
13
+ const url = `${getApiUrl()}${path}`;
14
+ const res = await fetch(url, {
15
+ ...opts,
16
+ headers: {
17
+ 'Authorization': `Bearer ${getApiKey()}`,
18
+ ...(opts.body && !(opts.body instanceof FormData) ? {'Content-Type': 'application/json'} : {}),
19
+ ...opts.headers,
20
+ },
21
+ signal: AbortSignal.timeout(opts.timeout || 15000),
22
+ });
23
+
24
+ if (!res.ok) {
25
+ const text = await res.text().catch(() => '');
26
+ let msg;
27
+ try { msg = JSON.parse(text).error; } catch { msg = text; }
28
+ throw new Error(`API ${res.status}: ${msg || res.statusText}`);
29
+ }
30
+
31
+ return res.json();
32
+ }
33
+
34
+ // ── Channels ──
35
+
36
+ export async function getChannels() {
37
+ const { data } = await apiFetch('/api/channels');
38
+ return data || [];
39
+ }
40
+
41
+ export async function getChannel(id) {
42
+ const { data } = await apiFetch(`/api/channels/${id}`);
43
+ return data;
44
+ }
45
+
46
+ export async function updateChannel(id, updates) {
47
+ return apiFetch(`/api/channels/${id}`, {
48
+ method: 'PATCH',
49
+ body: JSON.stringify(updates),
50
+ });
51
+ }
52
+
53
+ // ── Messages ──
54
+
55
+ export async function getPendingMessages() {
56
+ const { data } = await apiFetch('/api/messages/pending');
57
+ return data || [];
58
+ }
59
+
60
+ export async function insertMessage(row) {
61
+ const { data } = await apiFetch('/api/messages', {
62
+ method: 'POST',
63
+ body: JSON.stringify(row),
64
+ });
65
+ return data;
66
+ }
67
+
68
+ export async function syncMessages(rows) {
69
+ return apiFetch('/api/messages/sync', {
70
+ method: 'POST',
71
+ body: JSON.stringify({ rows }),
72
+ });
73
+ }
74
+
75
+ export async function updateMessage(id, updates) {
76
+ return apiFetch(`/api/messages/${id}`, {
77
+ method: 'PATCH',
78
+ body: JSON.stringify(updates),
79
+ });
80
+ }
81
+
82
+ // ── Cards ──
83
+
84
+ export async function insertCard(row) {
85
+ const { data } = await apiFetch('/api/cards', {
86
+ method: 'POST',
87
+ body: JSON.stringify(row),
88
+ });
89
+ return data;
90
+ }
91
+
92
+ // ── Upload ──
93
+
94
+ export async function uploadFile(filePath, fileData, contentType) {
95
+ const formData = new FormData();
96
+ formData.append('file', new Blob([fileData], { type: contentType }), filePath);
97
+ formData.append('path', filePath);
98
+ formData.append('contentType', contentType);
99
+
100
+ const { url } = await apiFetch('/api/upload', {
101
+ method: 'POST',
102
+ body: formData,
103
+ timeout: 30000,
104
+ });
105
+ return url;
106
+ }
107
+
108
+ // ── Dispatch (existing endpoint) ──
109
+
110
+ export async function dispatch(event) {
111
+ return apiFetch('/dispatch', {
112
+ method: 'POST',
113
+ body: JSON.stringify(event),
114
+ });
115
+ }
116
+
117
+ // ── Pair (no auth needed) ──
118
+
119
+ export async function pair(payload) {
120
+ const url = `${getApiUrl()}/dispatch`;
121
+ const res = await fetch(url, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ action: 'pair', ...payload }),
125
+ signal: AbortSignal.timeout(15000),
126
+ });
127
+ return res.json();
128
+ }