@ebowwa/channel-ssh 1.1.3 → 2.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/src/index.ts CHANGED
@@ -1,101 +1,85 @@
1
- #!/usr/bin/env bun
2
1
  /**
3
- * SSH Channel for GLM Daemon
2
+ * @ebowwa/channel-ssh
4
3
  *
5
- * Provides AI chat via file-based IPC - works with systemd and tmux
4
+ * SSH channel adapter implementing ChannelConnector.
6
5
  *
7
- * Usage:
8
- * Direct: bun run src/index.ts
9
- * With tmux wrapper: tmux new-session -s ssh-chat "channel-ssh-interactive"
6
+ * This package handles:
7
+ * - File-based IPC (reads from ~/.ssh-chat/in, writes to ~/.ssh-chat/out)
8
+ * - Status tracking (~/.ssh-chat/status)
9
+ * - Message normalization to ChannelMessage format
10
10
  *
11
- * Communication:
12
- * IN_FILE: ~/.ssh-chat/in (user writes messages here)
13
- * OUT_FILE: ~/.ssh-chat/out (AI responses here)
14
- * STATUS_FILE: ~/.ssh-chat/status (processing/idle)
11
+ * Intelligence (GLM, tools, memory) can be provided by:
12
+ * 1. This package (standalone mode with built-in echo handler)
13
+ * 2. External daemon/consumer (adapter mode via onMessage handler)
15
14
  *
16
- * Features:
17
- * - File-based IPC for systemd compatibility
18
- * - GLM-4.7 AI responses with retry logic (via @ebowwa/ai)
19
- * - Separate conversation memory from Telegram
20
- * - Tool support (read_file, run_command, etc.)
15
+ * Usage:
16
+ * Write message: echo "your message" > ~/.ssh-chat/in
17
+ * Read response: cat ~/.ssh-chat/out
18
+ * Check status: cat ~/.ssh-chat/status
21
19
  */
22
20
 
23
- import { execSync } from 'child_process';
24
- import { existsSync, readFileSync, writeFileSync, mkdirSync, watch } from 'fs';
25
- import { homedir } from 'os';
26
- import { join } from 'path';
27
-
28
- // ====================================================================
29
- // Configuration (all via environment variables - REQUIRED)
30
- // ====================================================================
31
-
32
- function requireEnv(name: string): string {
33
- const value = process.env[name];
34
- if (!value) {
35
- throw new Error(`Missing required environment variable: ${name}`);
36
- }
37
- return value;
38
- }
39
-
40
- function requireEnvInt(name: string): number {
41
- return parseInt(requireEnv(name), 10);
21
+ import { existsSync, readFileSync, watch, writeFileSync, mkdirSync } from "fs";
22
+ import { homedir } from "os";
23
+ import { join } from "path";
24
+
25
+ import {
26
+ type ChannelId,
27
+ type ChannelMessage,
28
+ type ChannelResponse,
29
+ type ChannelCapabilities,
30
+ type MessageSender,
31
+ type MessageContext,
32
+ type MessageHandler,
33
+ createChannelId,
34
+ } from "@ebowwa/channel-types";
35
+
36
+ // ============================================================
37
+ // CONFIG
38
+ // ============================================================
39
+
40
+ export interface SSHConfig {
41
+ /** Chat directory for IPC files (default: ~/.ssh-chat) */
42
+ chatDir?: string;
43
+ /** Polling interval in ms (default: 500) */
44
+ pollInterval?: number;
45
+ /** Memory limit - max messages to keep (default: 100) */
46
+ memoryLimit?: number;
47
+ /** Context limit for LLM (default: 20) */
48
+ contextLimit?: number;
42
49
  }
43
50
 
44
- function requireEnvFloat(name: string): number {
45
- return parseFloat(requireEnv(name));
51
+ export function createSSHConfigFromEnv(): SSHConfig {
52
+ return {
53
+ chatDir: process.env.SSH_CHAT_DIR,
54
+ pollInterval: process.env.SSH_POLL_INTERVAL
55
+ ? parseInt(process.env.SSH_POLL_INTERVAL, 10)
56
+ : undefined,
57
+ memoryLimit: process.env.SSH_MEMORY_LIMIT
58
+ ? parseInt(process.env.SSH_MEMORY_LIMIT, 10)
59
+ : undefined,
60
+ contextLimit: process.env.SSH_CONTEXT_LIMIT
61
+ ? parseInt(process.env.SSH_CONTEXT_LIMIT, 10)
62
+ : undefined,
63
+ };
46
64
  }
47
65
 
48
- const CONFIG = {
49
- chatDir: process.env.SSH_CHAT_DIR ?? join(homedir(), '.ssh-chat'), // only optional one
50
- model: requireEnv('GLM_MODEL'),
51
- maxRetries: requireEnvInt('GLM_MAX_RETRIES'),
52
- timeout: requireEnvInt('GLM_TIMEOUT_MS'),
53
- temperature: requireEnvFloat('GLM_TEMPERATURE'),
54
- maxTokens: requireEnvInt('GLM_MAX_TOKENS'),
55
- pollInterval: requireEnvInt('SSH_CHAT_POLL_MS'),
56
- memoryLimit: requireEnvInt('SSH_CHAT_MEMORY_LIMIT'),
57
- contextLimit: requireEnvInt('SSH_CHAT_CONTEXT_LIMIT'),
58
- };
59
-
60
- const IN_FILE = join(CONFIG.chatDir, 'in');
61
- const OUT_FILE = join(CONFIG.chatDir, 'out');
62
- const STATUS_FILE = join(CONFIG.chatDir, 'status');
63
- const MEMORY_FILE = join(CONFIG.chatDir, 'memory.json');
64
-
65
- // ====================================================================
66
- // Setup
67
- // ====================================================================
68
-
69
- function ensureDir(): void {
70
- if (!existsSync(CONFIG.chatDir)) {
71
- mkdirSync(CONFIG.chatDir, { recursive: true });
72
- }
73
- }
74
-
75
- function setStatus(status: 'idle' | 'processing' | 'error' | 'retrying'): void {
76
- writeFileSync(STATUS_FILE, JSON.stringify({ status, timestamp: Date.now() }));
77
- }
78
-
79
- function writeOutput(text: string): void {
80
- const timestamp = new Date().toISOString();
81
- writeFileSync(OUT_FILE, `[${timestamp}]\n${text}\n`);
82
- }
66
+ // ============================================================
67
+ // CONVERSATION MEMORY
68
+ // ============================================================
83
69
 
84
- // ====================================================================
85
- // Conversation Memory (Separate from Telegram)
86
- // ====================================================================
87
-
88
- interface Message {
89
- role: 'user' | 'assistant' | 'system';
70
+ interface StoredMessage {
71
+ role: "user" | "assistant" | "system";
90
72
  content: string;
91
73
  timestamp: number;
92
74
  }
93
75
 
94
- class ConversationMemory {
95
- private messages: Message[] = [];
76
+ export class ConversationMemory {
77
+ private messages: StoredMessage[] = [];
96
78
  private maxMessages: number;
79
+ private file: string;
97
80
 
98
- constructor(private file: string, maxMessages = CONFIG.memoryLimit) {
81
+ constructor(file: string, maxMessages = 100) {
82
+ this.file = file;
99
83
  this.maxMessages = maxMessages;
100
84
  this.load();
101
85
  }
@@ -103,7 +87,7 @@ class ConversationMemory {
103
87
  private load(): void {
104
88
  try {
105
89
  if (existsSync(this.file)) {
106
- const data = JSON.parse(readFileSync(this.file, 'utf-8'));
90
+ const data = JSON.parse(readFileSync(this.file, "utf-8"));
107
91
  this.messages = data.messages || [];
108
92
  }
109
93
  } catch {
@@ -115,11 +99,11 @@ class ConversationMemory {
115
99
  try {
116
100
  writeFileSync(this.file, JSON.stringify({ messages: this.messages }, null, 2));
117
101
  } catch (e) {
118
- console.error('Failed to save memory:', e);
102
+ console.error("Failed to save memory:", e);
119
103
  }
120
104
  }
121
105
 
122
- add(role: 'user' | 'assistant' | 'system', content: string): void {
106
+ add(role: "user" | "assistant" | "system", content: string): void {
123
107
  this.messages.push({ role, content, timestamp: Date.now() });
124
108
  if (this.messages.length > this.maxMessages) {
125
109
  this.messages = this.messages.slice(-this.maxMessages);
@@ -127,7 +111,7 @@ class ConversationMemory {
127
111
  this.save();
128
112
  }
129
113
 
130
- getContext(limit = 20): Message[] {
114
+ getContext(limit = 20): StoredMessage[] {
131
115
  return this.messages.slice(-limit);
132
116
  }
133
117
 
@@ -137,368 +121,319 @@ class ConversationMemory {
137
121
  }
138
122
  }
139
123
 
140
- // ====================================================================
141
- // Tool Definitions
142
- // ====================================================================
143
-
144
- interface Tool {
145
- name: string;
146
- description: string;
147
- parameters: Record<string, unknown>;
148
- handler: (args: Record<string, unknown>) => Promise<string>;
149
- }
150
-
151
- const TOOLS: Tool[] = [
152
- {
153
- name: 'read_file',
154
- description: 'Read a file from the filesystem.',
155
- parameters: {
156
- type: 'object',
157
- properties: { path: { type: 'string', description: 'File path to read' } },
158
- required: ['path']
159
- },
160
- handler: async (args) => {
161
- const path = args.path as string;
162
- try {
163
- if (!existsSync(path)) return `File not found: ${path}`;
164
- const content = readFileSync(path, 'utf-8');
165
- return content.length > 4000 ? content.slice(0, 4000) + '\n...[truncated]' : content;
166
- } catch (e) {
167
- return `Error: ${(e as Error).message}`;
168
- }
169
- }
170
- },
171
- {
172
- name: 'write_file',
173
- description: 'Write content to a file.',
174
- parameters: {
175
- type: 'object',
176
- properties: {
177
- path: { type: 'string' },
178
- content: { type: 'string' }
179
- },
180
- required: ['path', 'content']
124
+ // ============================================================
125
+ // SSH CHANNEL
126
+ // ============================================================
127
+
128
+ export class SSHChannel {
129
+ readonly id: ChannelId;
130
+ readonly label = "SSH";
131
+ readonly capabilities: ChannelCapabilities = {
132
+ supports: {
133
+ text: true,
134
+ media: false,
135
+ replies: false,
136
+ threads: false,
137
+ reactions: false,
138
+ editing: false,
139
+ streaming: false,
181
140
  },
182
- handler: async (args) => {
183
- try {
184
- writeFileSync(args.path as string, args.content as string);
185
- return `Wrote ${(args.content as string).length} bytes to ${args.path}`;
186
- } catch (e) {
187
- return `Error: ${(e as Error).message}`;
188
- }
189
- }
190
- },
191
- {
192
- name: 'run_command',
193
- description: 'Execute a shell command.',
194
- parameters: {
195
- type: 'object',
196
- properties: {
197
- command: { type: 'string' },
198
- cwd: { type: 'string' }
199
- },
200
- required: ['command']
141
+ rateLimits: {
142
+ messagesPerMinute: 120,
143
+ charactersPerMessage: 100000,
201
144
  },
202
- handler: async (args) => {
203
- const cmd = args.command as string;
204
- const blocked = ['rm -rf', 'mkfs', 'dd if=', '> /dev/'];
205
- if (blocked.some(b => cmd.includes(b))) return 'Blocked: dangerous command';
206
- try {
207
- const result = execSync(cmd, { timeout: 10000, cwd: args.cwd as string || process.cwd() });
208
- return result.toString() || '(no output)';
209
- } catch (e: any) {
210
- return e.stdout?.toString() || e.message;
211
- }
212
- }
213
- },
214
- {
215
- name: 'git_status',
216
- description: 'Check git repository status.',
217
- parameters: { type: 'object', properties: { cwd: { type: 'string' } } },
218
- handler: async (args) => {
219
- const cwd = (args.cwd as string) || process.cwd();
220
- try {
221
- const status = execSync('git status 2>&1', { cwd }).toString();
222
- const branch = execSync('git branch --show-current 2>&1', { cwd }).toString();
223
- return `Branch: ${branch}\n\n${status}`;
224
- } catch (e) {
225
- return `Error: ${(e as Error).message}`;
226
- }
227
- }
228
- },
229
- {
230
- name: 'system_info',
231
- description: 'Get system resource info.',
232
- parameters: { type: 'object', properties: {} },
233
- handler: async () => {
234
- try {
235
- const cpu = execSync('nproc 2>/dev/null || echo "unknown"').toString().trim();
236
- const mem = execSync('free -h 2>/dev/null | grep Mem || echo "unknown"').toString().trim();
237
- const disk = execSync('df -h / 2>/dev/null | tail -1 || echo "unknown"').toString().trim();
238
- return `CPU: ${cpu} cores\nMemory: ${mem}\nDisk: ${disk}`;
239
- } catch (e) {
240
- return `Error: ${(e as Error).message}`;
241
- }
242
- }
145
+ };
146
+
147
+ private config: Required<SSHConfig>;
148
+ private memory: ConversationMemory;
149
+ private messageHandler?: MessageHandler;
150
+ private connected = false;
151
+ private watcher?: ReturnType<typeof watch>;
152
+ private pollInterval?: ReturnType<typeof setInterval>;
153
+ private lastContent = "";
154
+ private messageCounter = 0;
155
+
156
+ // File paths
157
+ private readonly IN_FILE: string;
158
+ private readonly OUT_FILE: string;
159
+ private readonly STATUS_FILE: string;
160
+ private readonly MEMORY_FILE: string;
161
+
162
+ constructor(config: SSHConfig = {}) {
163
+ this.config = {
164
+ chatDir: config.chatDir ?? join(homedir(), ".ssh-chat"),
165
+ pollInterval: config.pollInterval ?? 500,
166
+ memoryLimit: config.memoryLimit ?? 100,
167
+ contextLimit: config.contextLimit ?? 20,
168
+ };
169
+
170
+ this.id = createChannelId("ssh", "default");
171
+
172
+ this.IN_FILE = join(this.config.chatDir, "in");
173
+ this.OUT_FILE = join(this.config.chatDir, "out");
174
+ this.STATUS_FILE = join(this.config.chatDir, "status");
175
+ this.MEMORY_FILE = join(this.config.chatDir, "memory.json");
176
+
177
+ this.memory = new ConversationMemory(this.MEMORY_FILE, this.config.memoryLimit);
243
178
  }
244
- ];
245
179
 
246
- function getGLMTools() {
247
- return TOOLS.map(t => ({
248
- type: 'function',
249
- function: { name: t.name, description: t.description, parameters: t.parameters }
250
- }));
251
- }
180
+ // ============================================================
181
+ // ChannelConnector Implementation
182
+ // ============================================================
252
183
 
253
- async function executeTool(name: string, args: Record<string, unknown>): Promise<string> {
254
- const tool = TOOLS.find(t => t.name === name);
255
- if (tool) return tool.handler(args);
256
- return `Unknown tool: ${name}`;
257
- }
184
+ async start(): Promise<void> {
185
+ console.log("[SSHChannel] Starting...");
258
186
 
259
- // ====================================================================
260
- // GLM API Client (direct fetch with tools + retry logic)
261
- // ====================================================================
187
+ // Ensure directory exists
188
+ this.ensureDir();
262
189
 
263
- const GLM_API_ENDPOINT = 'https://api.z.ai/api/coding/paas/v4/chat/completions';
190
+ // Create empty files if they don't exist
191
+ if (!existsSync(this.IN_FILE)) writeFileSync(this.IN_FILE, "");
192
+ if (!existsSync(this.OUT_FILE)) writeFileSync(this.OUT_FILE, "Ready. Send a message.\n");
264
193
 
265
- function getAPIKey(): string {
266
- const envKey = process.env.ZAI_API_KEY || process.env.Z_AI_API_KEY || process.env.GLM_API_KEY;
267
- if (envKey) return envKey;
194
+ this.setStatus("idle");
268
195
 
269
- const keysJson = process.env.ZAI_API_KEYS || process.env.Z_AI_API_KEYS;
270
- if (keysJson) {
271
- try {
272
- const keys = JSON.parse(keysJson);
273
- if (Array.isArray(keys) && keys.length > 0) {
274
- return keys[Math.floor(Math.random() * keys.length)];
196
+ // Setup file watcher
197
+ this.watcher = watch(this.config.chatDir, (eventType, filename) => {
198
+ if (filename === "in" && eventType === "change") {
199
+ this.processIncoming();
275
200
  }
276
- } catch {}
277
- }
278
-
279
- throw new Error('No API key found. Set ZAI_API_KEY env var.');
280
- }
281
-
282
- function sleep(ms: number): Promise<void> {
283
- return new Promise(resolve => setTimeout(resolve, ms));
284
- }
285
-
286
- function calculateBackoff(retryCount: number): number {
287
- return Math.min(1000 * Math.pow(2, retryCount), 10000);
288
- }
289
-
290
- async function callGLM(messages: Message[], retryCount = 0): Promise<string> {
291
- const apiKey = getAPIKey();
292
-
293
- try {
294
- const controller = new AbortController();
295
- const timeoutId = setTimeout(() => controller.abort(), CONFIG.timeout);
296
-
297
- const response = await fetch(GLM_API_ENDPOINT, {
298
- method: 'POST',
299
- headers: {
300
- 'Content-Type': 'application/json',
301
- 'Authorization': `Bearer ${apiKey}`
302
- },
303
- signal: controller.signal,
304
- body: JSON.stringify({
305
- model: CONFIG.model,
306
- messages: messages.map(m => ({ role: m.role, content: m.content })),
307
- tools: getGLMTools(),
308
- temperature: CONFIG.temperature,
309
- max_tokens: CONFIG.maxTokens
310
- })
311
201
  });
312
202
 
313
- clearTimeout(timeoutId);
314
-
315
- if (!response.ok) {
316
- const text = await response.text();
203
+ // Setup polling as backup (watch can be unreliable)
204
+ this.pollInterval = setInterval(() => {
205
+ try {
206
+ const content = readFileSync(this.IN_FILE, "utf-8").trim();
207
+ if (content && content !== this.lastContent) {
208
+ this.processIncoming();
209
+ }
210
+ } catch {}
211
+ }, this.config.pollInterval);
212
+
213
+ this.connected = true;
214
+ console.log("[SSHChannel] Started");
215
+ console.log(` IN_FILE: ${this.IN_FILE}`);
216
+ console.log(` OUT_FILE: ${this.OUT_FILE}`);
217
+ console.log(` STATUS: ${this.STATUS_FILE}`);
218
+ }
317
219
 
318
- // Retry on 429 (rate limit) or 5xx errors
319
- if ((response.status === 429 || response.status >= 500) && retryCount < CONFIG.maxRetries) {
320
- const backoff = calculateBackoff(retryCount);
321
- console.log(`GLM API error ${response.status}, retrying in ${backoff}ms (${retryCount + 1}/${CONFIG.maxRetries})`);
322
- await sleep(backoff);
323
- return callGLM(messages, retryCount + 1);
324
- }
220
+ async stop(): Promise<void> {
221
+ console.log("[SSHChannel] Stopping...");
325
222
 
326
- throw new Error(`GLM API error: ${response.status} - ${text}`);
223
+ if (this.watcher) {
224
+ this.watcher.close();
225
+ this.watcher = undefined;
327
226
  }
328
227
 
329
- const data = await response.json();
330
- const choice = data.choices?.[0];
331
-
332
- if (!choice) {
333
- throw new Error('No response from GLM');
228
+ if (this.pollInterval) {
229
+ clearInterval(this.pollInterval);
230
+ this.pollInterval = undefined;
334
231
  }
335
232
 
336
- // Handle tool calls
337
- if (choice.message?.tool_calls && choice.message.tool_calls.length > 0) {
338
- const toolResults: string[] = [];
339
-
340
- for (const tc of choice.message.tool_calls) {
341
- const toolName = tc.function?.name;
342
- const toolArgs = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
343
- const result = await executeTool(toolName, toolArgs);
344
- toolResults.push(`[${toolName}]: ${result}`);
345
- }
346
-
347
- // Add assistant message with tool calls and user message with results
348
- const updatedMessages = [
349
- ...messages,
350
- { role: 'assistant' as const, content: choice.message.content || '', timestamp: Date.now() },
351
- { role: 'user' as const, content: `Tool results:\n${toolResults.join('\n')}`, timestamp: Date.now() }
352
- ];
353
-
354
- // Continue conversation with tool results
355
- return callGLM(updatedMessages, 0);
356
- }
357
-
358
- return choice.message?.content || '(no response)';
359
-
360
- } catch (error) {
361
- // Retry on network errors or timeout
362
- if ((error instanceof Error && (error.name === 'AbortError' || error.message.includes('fetch'))) && retryCount < CONFIG.maxRetries) {
363
- const backoff = calculateBackoff(retryCount);
364
- console.log(`Network error, retrying in ${backoff}ms (${retryCount + 1}/${CONFIG.maxRetries})`);
365
- await sleep(backoff);
366
- return callGLM(messages, retryCount + 1);
367
- }
368
- throw error;
233
+ this.connected = false;
234
+ console.log("[SSHChannel] Stopped");
369
235
  }
370
- }
371
236
 
372
- // ====================================================================
373
- // Process Message
374
- // ====================================================================
237
+ /**
238
+ * Set the message handler. The daemon/consumer provides intelligence.
239
+ */
240
+ onMessage(handler: MessageHandler): void {
241
+ this.messageHandler = handler;
242
+ }
375
243
 
376
- async function processMessage(input: string, memory: ConversationMemory): Promise<string> {
377
- // Handle commands
378
- if (input.startsWith('/')) {
379
- if (input === '/clear') {
380
- memory.clear();
381
- return 'Memory cleared.';
382
- }
383
- if (input === '/help') {
384
- return `Commands:
385
- /clear - Clear conversation memory
386
- /help - Show this help
387
- /status - Show system status
244
+ /**
245
+ * Send a response back to SSH channel (writes to OUT_FILE).
246
+ */
247
+ async send(response: ChannelResponse): Promise<void> {
248
+ const text = response.content.text;
249
+ const timestamp = new Date().toISOString();
250
+ writeFileSync(this.OUT_FILE, `[${timestamp}]\n${text}\n`);
388
251
 
389
- Just type a message to chat with AI.`;
390
- }
391
- if (input === '/status') {
392
- return `Status: running
393
- Memory file: ${MEMORY_FILE}
394
- Chat dir: ${CONFIG.chatDir}`;
395
- }
396
- return `Unknown command: ${input}. Type /help for available commands.`;
252
+ // Store in memory
253
+ this.memory.add("assistant", text);
397
254
  }
398
255
 
399
- // Regular message - get AI response
400
- memory.add('user', input);
401
- const messages = memory.getContext(CONFIG.contextLimit);
402
- return await callGLM(messages);
403
- }
256
+ isConnected(): boolean {
257
+ return this.connected;
258
+ }
404
259
 
405
- // ====================================================================
406
- // Main Loop - File Watcher
407
- // ====================================================================
260
+ // ============================================================
261
+ // Public Helpers
262
+ // ============================================================
408
263
 
409
- async function main() {
410
- console.log('SSH Chat Channel starting...');
411
- console.log(`Chat dir: ${CONFIG.chatDir}`);
412
- console.log(`Memory: ${MEMORY_FILE}`);
413
- console.log('');
414
- console.log('Usage:');
415
- console.log(` Write message: echo "your message" > ${IN_FILE}`);
416
- console.log(` Read response: cat ${OUT_FILE}`);
417
- console.log('');
418
-
419
- // Ensure directories exist
420
- ensureDir();
421
-
422
- // Verify API key is configured
423
- try {
424
- getAPIKey();
425
- console.log('GLM client initialized with retry support');
426
- } catch (e) {
427
- console.error('Failed to initialize GLM client:', (e as Error).message);
428
- process.exit(1);
264
+ /**
265
+ * Get the conversation memory.
266
+ */
267
+ getMemory(): ConversationMemory {
268
+ return this.memory;
429
269
  }
430
270
 
431
- // Initialize memory
432
- const memory = new ConversationMemory(MEMORY_FILE);
433
- memory.add('system', `You are an AI assistant accessible via SSH.
434
- You are helpful, concise, and can execute tools to help the user.
435
- This is a private SSH channel separate from any Telegram or other chat interfaces.`);
436
-
437
- // Create empty files if they don't exist
438
- if (!existsSync(IN_FILE)) writeFileSync(IN_FILE, '');
439
- if (!existsSync(OUT_FILE)) writeFileSync(OUT_FILE, 'Ready. Send a message.\n');
271
+ /**
272
+ * Get the input file path.
273
+ */
274
+ getInFile(): string {
275
+ return this.IN_FILE;
276
+ }
440
277
 
441
- setStatus('idle');
278
+ /**
279
+ * Get the output file path.
280
+ */
281
+ getOutFile(): string {
282
+ return this.OUT_FILE;
283
+ }
442
284
 
443
- // Track last processed content
444
- let lastContent = '';
285
+ /**
286
+ * Get the status file path.
287
+ */
288
+ getStatusFile(): string {
289
+ return this.STATUS_FILE;
290
+ }
445
291
 
446
- console.log('Watching for messages...');
292
+ // ============================================================
293
+ // Internal Methods
294
+ // ============================================================
447
295
 
448
- // Watch for file changes
449
- const watcher = watch(CONFIG.chatDir, (eventType, filename) => {
450
- if (filename === 'in' && eventType === 'change') {
451
- processIncoming();
296
+ private ensureDir(): void {
297
+ if (!existsSync(this.config.chatDir)) {
298
+ mkdirSync(this.config.chatDir, { recursive: true });
452
299
  }
453
- });
300
+ }
301
+
302
+ private setStatus(status: "idle" | "processing" | "error"): void {
303
+ writeFileSync(this.STATUS_FILE, JSON.stringify({ status, timestamp: Date.now() }));
304
+ }
454
305
 
455
- async function processIncoming() {
306
+ private async processIncoming(): Promise<void> {
456
307
  try {
457
- const content = readFileSync(IN_FILE, 'utf-8').trim();
308
+ const content = readFileSync(this.IN_FILE, "utf-8").trim();
458
309
 
459
310
  // Skip if same as last or empty
460
- if (!content || content === lastContent) return;
311
+ if (!content || content === this.lastContent) return;
461
312
 
462
- lastContent = content;
463
- setStatus('processing');
313
+ this.lastContent = content;
314
+ this.setStatus("processing");
464
315
 
465
- console.log(`[${new Date().toISOString()}] Processing: ${content.slice(0, 50)}...`);
316
+ console.log(`[SSHChannel] Processing: ${content.slice(0, 50)}...`);
466
317
 
467
318
  // Clear input file after reading
468
- writeFileSync(IN_FILE, '');
469
-
470
- // Process message
471
- const response = await processMessage(content, memory);
472
-
473
- // Write response
474
- writeOutput(response);
475
- memory.add('assistant', response);
319
+ writeFileSync(this.IN_FILE, "");
320
+
321
+ // Create normalized message
322
+ const message = this.createChannelMessage(content);
323
+
324
+ // Store in memory
325
+ this.memory.add("user", content);
326
+
327
+ // Route to handler if set
328
+ if (this.messageHandler) {
329
+ try {
330
+ const response = await this.messageHandler(message);
331
+ if (response) {
332
+ await this.send(response);
333
+ }
334
+ } catch (error) {
335
+ console.error("[SSHChannel] Handler error:", error);
336
+ writeFileSync(
337
+ this.OUT_FILE,
338
+ `[${new Date().toISOString()}]\nError: ${(error as Error).message}\n`
339
+ );
340
+ this.setStatus("error");
341
+ return;
342
+ }
343
+ } else {
344
+ // No handler - echo mode
345
+ await this.send({
346
+ content: { text: `Echo: ${content}` },
347
+ replyTo: { messageId: message.messageId, channelId: message.channelId },
348
+ });
349
+ }
476
350
 
477
- setStatus('idle');
478
- console.log(`[${new Date().toISOString()}] Response sent`);
351
+ this.setStatus("idle");
352
+ console.log("[SSHChannel] Response sent");
479
353
  } catch (error) {
480
- console.error('Error:', error);
481
- setStatus('error');
482
- writeOutput(`Error: ${(error as Error).message}`);
354
+ console.error("[SSHChannel] Error:", error);
355
+ this.setStatus("error");
483
356
  }
484
357
  }
485
358
 
486
- // Also poll as backup (watch can be unreliable)
487
- setInterval(() => {
488
- try {
489
- const content = readFileSync(IN_FILE, 'utf-8').trim();
490
- if (content && content !== lastContent) {
491
- processIncoming();
492
- }
493
- } catch {}
494
- }, CONFIG.pollInterval);
359
+ /**
360
+ * Normalize SSH input to ChannelMessage format.
361
+ */
362
+ private createChannelMessage(text: string): ChannelMessage {
363
+ this.messageCounter++;
364
+ const messageId = `ssh-${Date.now()}-${this.messageCounter}`;
365
+
366
+ const sender: MessageSender = {
367
+ id: "ssh-user",
368
+ username: "ssh",
369
+ displayName: "SSH User",
370
+ isBot: false,
371
+ };
372
+
373
+ const context: MessageContext = {
374
+ isDM: true,
375
+ metadata: {
376
+ source: "file-ipc",
377
+ chatDir: this.config.chatDir,
378
+ },
379
+ };
380
+
381
+ return {
382
+ messageId,
383
+ channelId: this.id,
384
+ timestamp: new Date(),
385
+ sender,
386
+ text,
387
+ context,
388
+ };
389
+ }
390
+ }
391
+
392
+ // ============================================================
393
+ // FACTORY
394
+ // ============================================================
495
395
 
496
- // Keep running
497
- process.on('SIGINT', () => {
498
- console.log('\nShutting down...');
499
- watcher.close();
396
+ export function createSSHChannel(config?: SSHConfig): SSHChannel {
397
+ return new SSHChannel(config);
398
+ }
399
+
400
+ // ============================================================
401
+ // MAIN ENTRY POINT (for standalone testing)
402
+ // ============================================================
403
+
404
+ async function main() {
405
+ const config = createSSHConfigFromEnv();
406
+ const channel = createSSHChannel(config);
407
+
408
+ // Example: Echo handler for testing
409
+ channel.onMessage(async (msg) => {
410
+ console.log(`Received: ${msg.text}`);
411
+ return {
412
+ content: { text: `Echo: ${msg.text}` },
413
+ replyTo: { messageId: msg.messageId, channelId: msg.channelId },
414
+ };
415
+ });
416
+
417
+ process.on("SIGINT", async () => {
418
+ await channel.stop();
419
+ process.exit(0);
420
+ });
421
+
422
+ process.on("SIGTERM", async () => {
423
+ await channel.stop();
500
424
  process.exit(0);
501
425
  });
426
+
427
+ await channel.start();
502
428
  }
503
429
 
504
- main().catch(console.error);
430
+ // Only run main when executed directly (not when imported)
431
+ if (import.meta.main) {
432
+ main().catch(console.error);
433
+ }
434
+
435
+ // ============================================================
436
+ // PLUGIN SYSTEM
437
+ // ============================================================
438
+
439
+ export { createSSHPlugin, sshPlugin, type ResolvedSSHAccount } from "./plugin.js";