@thesammykins/tether 1.6.0 → 1.7.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/config.ts CHANGED
@@ -57,7 +57,8 @@ const CONFIG_KEYS: Record<string, ConfigKeyMeta> = {
57
57
 
58
58
  // Agent
59
59
  AGENT_TYPE: { section: 'agent', default: 'claude', description: 'Agent type (claude, opencode, codex)' },
60
- CLAUDE_WORKING_DIR: { section: 'agent', default: '', description: 'Default working directory for agent sessions' },
60
+ /** @deprecated Use named projects instead (tether project add). Kept for backward compatibility. */
61
+ CLAUDE_WORKING_DIR: { section: 'agent', default: '', description: 'Default working directory for agent sessions (deprecated: use projects)' },
61
62
  CLAUDE_BIN: { section: 'agent', default: '', description: 'Override path to Claude CLI binary' },
62
63
  OPENCODE_BIN: { section: 'agent', default: '', description: 'Override path to OpenCode CLI binary' },
63
64
  CODEX_BIN: { section: 'agent', default: '', description: 'Override path to Codex CLI binary' },
@@ -402,3 +403,34 @@ export const CONFIG_PATHS = {
402
403
  get CONFIG_PATH() { return getConfigPath(); },
403
404
  get SECRETS_PATH() { return getSecretsPath(); },
404
405
  } as const;
406
+
407
+ /**
408
+ * Migrate CLAUDE_WORKING_DIR to a named project.
409
+ *
410
+ * If CLAUDE_WORKING_DIR is set and no projects exist in the DB,
411
+ * creates a default project from the directory name and path.
412
+ * Safe to call multiple times — no-ops when projects already exist.
413
+ */
414
+ export function migrateWorkingDirToProject(): void {
415
+ // Lazy-import to avoid circular dependency at module load time
416
+ const { listProjects, createProject, db } = require('./db.js');
417
+ const { resolve: resolvePath, basename } = require('path');
418
+
419
+ const workingDir = resolve('CLAUDE_WORKING_DIR');
420
+ if (!workingDir) return;
421
+
422
+ // Use a transaction to prevent TOCTOU race: concurrent calls could both
423
+ // see 0 projects and create duplicates without atomic check-and-insert.
424
+ db.transaction(() => {
425
+ const existing = listProjects() as { name: string }[];
426
+ if (existing.length > 0) {
427
+ console.log('[config] CLAUDE_WORKING_DIR is deprecated. Use named projects instead (tether project add).');
428
+ return;
429
+ }
430
+
431
+ const resolvedPath = resolvePath(workingDir);
432
+ const dirName = basename(resolvedPath);
433
+ createProject(dirName, resolvedPath, true);
434
+ console.log(`[config] Migrated CLAUDE_WORKING_DIR to project "${dirName}"`);
435
+ })();
436
+ }
package/src/db.ts CHANGED
@@ -18,7 +18,7 @@ import { mkdirSync } from 'fs';
18
18
  import { dirname } from 'path';
19
19
  try {
20
20
  mkdirSync(dirname(DB_PATH), { recursive: true });
21
- } catch {}
21
+ } catch { /* Directory already exists or permission issue */ }
22
22
 
23
23
  // Open database
24
24
  export const db = new Database(DB_PATH);
@@ -72,6 +72,26 @@ db.run(`
72
72
  )
73
73
  `);
74
74
 
75
+ // Create projects table
76
+ db.run(`
77
+ CREATE TABLE IF NOT EXISTS projects (
78
+ name TEXT PRIMARY KEY,
79
+ path TEXT NOT NULL,
80
+ is_default INTEGER DEFAULT 0,
81
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
82
+ )
83
+ `);
84
+
85
+ // Add project_name column to channels table (migration)
86
+ try {
87
+ db.run(`ALTER TABLE channels ADD COLUMN project_name TEXT REFERENCES projects(name)`);
88
+ } catch { /* Column may already exist */ }
89
+
90
+ // Add project_name column to threads table (migration)
91
+ try {
92
+ db.run(`ALTER TABLE threads ADD COLUMN project_name TEXT REFERENCES projects(name)`);
93
+ } catch { /* Column may already exist */ }
94
+
75
95
  // Note: rate limiting is handled in-memory, see src/middleware/rate-limiter.ts
76
96
 
77
97
  console.log(`[db] SQLite database ready at ${DB_PATH}`);
@@ -116,3 +136,89 @@ export function updateSessionId(threadId: string, sessionId: string): void {
116
136
  UPDATE threads SET session_id = ? WHERE thread_id = ?
117
137
  `, [sessionId, threadId]);
118
138
  }
139
+
140
+ // --- Project types and helpers ---
141
+
142
+ export interface Project {
143
+ name: string;
144
+ path: string;
145
+ is_default: number;
146
+ created_at: string;
147
+ }
148
+
149
+ export function createProject(name: string, path: string, isDefault?: boolean): void {
150
+ db.transaction(() => {
151
+ if (isDefault) {
152
+ db.run(`UPDATE projects SET is_default = 0 WHERE is_default = 1`);
153
+ }
154
+ db.run(`
155
+ INSERT INTO projects (name, path, is_default) VALUES (?, ?, ?)
156
+ ON CONFLICT(name) DO UPDATE SET path = ?, is_default = ?
157
+ `, [name, path, isDefault ? 1 : 0, path, isDefault ? 1 : 0]);
158
+ })();
159
+ }
160
+
161
+ export function getProject(name: string): Project | null {
162
+ return db.query('SELECT * FROM projects WHERE name = ?')
163
+ .get(name) as Project | null;
164
+ }
165
+
166
+ export function getDefaultProject(): Project | null {
167
+ return db.query('SELECT * FROM projects WHERE is_default = 1')
168
+ .get() as Project | null;
169
+ }
170
+
171
+ export function listProjects(): Project[] {
172
+ return db.query('SELECT * FROM projects ORDER BY name')
173
+ .all() as Project[];
174
+ }
175
+
176
+ export function deleteProject(name: string): void {
177
+ db.transaction(() => {
178
+ db.run(`UPDATE channels SET project_name = NULL WHERE project_name = ?`, [name]);
179
+ db.run(`UPDATE threads SET project_name = NULL WHERE project_name = ?`, [name]);
180
+ db.run(`DELETE FROM projects WHERE name = ?`, [name]);
181
+ })();
182
+ }
183
+
184
+ export function setProjectDefault(name: string): void {
185
+ db.transaction(() => {
186
+ db.run(`UPDATE projects SET is_default = 0 WHERE is_default = 1`);
187
+ db.run(`UPDATE projects SET is_default = 1 WHERE name = ?`, [name]);
188
+ })();
189
+ }
190
+
191
+ export function getChannelProject(channelId: string): Project | null {
192
+ const row = db.query(`
193
+ SELECT p.* FROM projects p
194
+ JOIN channels c ON c.project_name = p.name
195
+ WHERE c.channel_id = ?
196
+ `).get(channelId) as Project | null;
197
+ return row;
198
+ }
199
+
200
+ export function setChannelProject(channelId: string, projectName: string): void {
201
+ // Look up project path to keep working_dir in sync for legacy code paths
202
+ const project = db.query('SELECT path FROM projects WHERE name = ?')
203
+ .get(projectName) as { path: string } | null;
204
+ const workingDir = project?.path ?? null;
205
+ db.run(`
206
+ INSERT INTO channels (channel_id, project_name, working_dir) VALUES (?, ?, ?)
207
+ ON CONFLICT(channel_id) DO UPDATE SET project_name = ?, working_dir = COALESCE(?, working_dir), updated_at = CURRENT_TIMESTAMP
208
+ `, [channelId, projectName, workingDir, projectName, workingDir]);
209
+ }
210
+
211
+ export function getThreadProject(threadId: string): Project | null {
212
+ const row = db.query(`
213
+ SELECT p.* FROM projects p
214
+ JOIN threads t ON t.project_name = p.name
215
+ WHERE t.thread_id = ?
216
+ `).get(threadId) as Project | null;
217
+ return row;
218
+ }
219
+
220
+ export function setThreadProject(threadId: string, projectName: string): void {
221
+ db.run(`
222
+ UPDATE threads SET project_name = ? WHERE thread_id = ?
223
+ `, [projectName, threadId]);
224
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Project management feature - shared handlers for slash and text commands.
3
+ *
4
+ * Both /cord project ... and !project ... dispatch into these functions
5
+ * so the business logic lives in one place.
6
+ */
7
+
8
+ import { existsSync, realpathSync } from 'fs';
9
+ import { resolve } from 'path';
10
+ import {
11
+ createProject,
12
+ getProject,
13
+ listProjects,
14
+ setProjectDefault,
15
+ setChannelProject,
16
+ db,
17
+ } from '../db.js';
18
+ import type { Project } from '../db.js';
19
+
20
+ const log = (msg: string) => process.stdout.write(`[projects] ${msg}\n`);
21
+
22
+ // Allowed working directories (read once, shared with bot.ts logic)
23
+ const ALLOWED_DIRS = process.env.CORD_ALLOWED_DIRS
24
+ ? process.env.CORD_ALLOWED_DIRS.split(',').map(d => resolve(d.trim()))
25
+ : null;
26
+
27
+ /**
28
+ * Validate a directory path exists and is within CORD_ALLOWED_DIRS.
29
+ * Returns null when valid, or an error string.
30
+ */
31
+ function validatePath(dir: string): string | null {
32
+ const resolved = resolve(dir);
33
+
34
+ if (!existsSync(resolved)) {
35
+ return `Directory not found: \`${dir}\``;
36
+ }
37
+
38
+ let realPath: string;
39
+ try {
40
+ realPath = realpathSync(resolved);
41
+ } catch (error) {
42
+ return `Cannot resolve path: \`${dir}\` (${error instanceof Error ? error.message : String(error)})`;
43
+ }
44
+
45
+ if (!ALLOWED_DIRS) {
46
+ return null;
47
+ }
48
+
49
+ const isAllowed = ALLOWED_DIRS.some(allowed => {
50
+ let allowedReal: string;
51
+ try {
52
+ allowedReal = realpathSync(allowed);
53
+ } catch {
54
+ return false;
55
+ }
56
+ return realPath === allowedReal || realPath.startsWith(allowedReal + '/');
57
+ });
58
+
59
+ if (!isAllowed) {
60
+ return `Directory not in allowed list. Allowed: ${ALLOWED_DIRS.join(', ')}`;
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ export interface CommandResult {
67
+ success: boolean;
68
+ message: string;
69
+ }
70
+
71
+ /**
72
+ * Register a new project.
73
+ */
74
+ export function handleProjectAdd(name: string, path: string): CommandResult {
75
+ const validationError = validatePath(path);
76
+ if (validationError) {
77
+ return { success: false, message: validationError };
78
+ }
79
+
80
+ const resolvedPath = resolve(path);
81
+
82
+ try {
83
+ createProject(name, resolvedPath);
84
+ log(`Project '${name}' registered at ${resolvedPath}`);
85
+ return { success: true, message: `Project **${name}** registered at \`${resolvedPath}\`` };
86
+ } catch (error) {
87
+ const msg = error instanceof Error ? error.message : String(error);
88
+ log(`Failed to create project '${name}': ${msg}`);
89
+ return { success: false, message: `Failed to register project: ${msg}` };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * List all registered projects, returning both raw data and a formatted string.
95
+ */
96
+ export function handleProjectList(): { projects: Project[]; formatted: string } {
97
+ const projects = listProjects();
98
+
99
+ if (projects.length === 0) {
100
+ return { projects, formatted: 'No projects registered. Use `/cord project add` or `!project add <name> <path>` to add one.' };
101
+ }
102
+
103
+ const lines = projects.map(p => {
104
+ const def = p.is_default ? ' **(default)**' : '';
105
+ return `- **${p.name}**${def} — \`${p.path}\``;
106
+ });
107
+
108
+ return { projects, formatted: `**Registered Projects**\n${lines.join('\n')}` };
109
+ }
110
+
111
+ /**
112
+ * Set a project as the global default.
113
+ */
114
+ export function handleProjectDefault(name: string): CommandResult {
115
+ const project = getProject(name);
116
+ if (!project) {
117
+ return { success: false, message: `Project **${name}** not found.` };
118
+ }
119
+
120
+ try {
121
+ setProjectDefault(name);
122
+ log(`Project '${name}' set as default`);
123
+ return { success: true, message: `Project **${name}** set as default.` };
124
+ } catch (error) {
125
+ const msg = error instanceof Error ? error.message : String(error);
126
+ return { success: false, message: `Failed to set default: ${msg}` };
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Bind a project to a specific channel.
132
+ */
133
+ export function handleProjectUse(channelId: string, name: string): CommandResult {
134
+ const project = getProject(name);
135
+ if (!project) {
136
+ return { success: false, message: `Project **${name}** not found.` };
137
+ }
138
+
139
+ try {
140
+ setChannelProject(channelId, name);
141
+ log(`Channel ${channelId} bound to project '${name}'`);
142
+ return {
143
+ success: true,
144
+ message: `Channel now uses project **${name}** (\`${project.path}\`).`,
145
+ };
146
+ } catch (error) {
147
+ const msg = error instanceof Error ? error.message : String(error);
148
+ return { success: false, message: `Failed to set channel project: ${msg}` };
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Validate that a session ID exists in the threads table.
154
+ * Returns the thread row if found, or null.
155
+ */
156
+ export function findSession(sessionId: string): { thread_id: string; session_id: string; working_dir: string | null; project_name: string | null } | null {
157
+ // Try exact match first
158
+ const exact = db.query('SELECT thread_id, session_id, working_dir, project_name FROM threads WHERE session_id = ?')
159
+ .get(sessionId) as { thread_id: string; session_id: string; working_dir: string | null; project_name: string | null } | null;
160
+ if (exact) return exact;
161
+
162
+ // Try prefix match (user may supply truncated ID)
163
+ // Escape SQL LIKE wildcards in user input to prevent unintended matches
164
+ const escaped = sessionId.replace(/[%_]/g, '\\$&');
165
+ const prefix = db.query("SELECT thread_id, session_id, working_dir, project_name FROM threads WHERE session_id LIKE ? ESCAPE '\\'")
166
+ .get(`${escaped}%`) as { thread_id: string; session_id: string; working_dir: string | null; project_name: string | null } | null;
167
+ return prefix;
168
+ }
169
+
170
+ /**
171
+ * Handle session attach - validate the session exists and return info.
172
+ * Thread creation happens in bot.ts since it needs Discord API.
173
+ */
174
+ export function handleSessionAttach(sessionId: string): CommandResult & { session?: { thread_id: string; session_id: string; working_dir: string | null; project_name: string | null } } {
175
+ const session = findSession(sessionId);
176
+ if (!session) {
177
+ return { success: false, message: `Session \`${sessionId}\` not found.` };
178
+ }
179
+
180
+ log(`Attach requested for session ${session.session_id}`);
181
+ return {
182
+ success: true,
183
+ message: `Attaching to session \`${session.session_id.slice(0, 8)}...\``,
184
+ session,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * List recent sessions for autocomplete. Returns session IDs with age info.
190
+ */
191
+ export function getRecentSessions(filter: string, limit = 25): Array<{ name: string; value: string }> {
192
+ const rows = db.query(`
193
+ SELECT session_id, created_at FROM threads
194
+ ORDER BY created_at DESC
195
+ LIMIT 50
196
+ `).all() as Array<{ session_id: string; created_at: string }>;
197
+
198
+ const now = Date.now();
199
+ return rows
200
+ .filter(r => r.session_id.toLowerCase().includes(filter.toLowerCase()))
201
+ .slice(0, limit)
202
+ .map(r => {
203
+ const age = formatRelativeTime(r.created_at, now);
204
+ const short = r.session_id.slice(0, 8);
205
+ return { name: `${short}... (${age})`, value: r.session_id };
206
+ });
207
+ }
208
+
209
+ function formatRelativeTime(dateStr: string, now: number): string {
210
+ const ms = now - new Date(dateStr).getTime();
211
+ const seconds = Math.floor(ms / 1000);
212
+ if (seconds < 60) return `${seconds}s ago`;
213
+ const minutes = Math.floor(seconds / 60);
214
+ if (minutes < 60) return `${minutes}m ago`;
215
+ const hours = Math.floor(minutes / 60);
216
+ if (hours < 24) return `${hours}h ago`;
217
+ const days = Math.floor(hours / 24);
218
+ return `${days}d ago`;
219
+ }
package/src/queue.ts CHANGED
@@ -40,5 +40,6 @@ export interface ClaudeJob {
40
40
  userId: string;
41
41
  username: string;
42
42
  workingDir?: string;
43
+ projectName?: string;
43
44
  channelContext?: string;
44
45
  }
package/src/worker.ts CHANGED
@@ -9,15 +9,28 @@
9
9
 
10
10
  import { Worker, Job } from 'bullmq';
11
11
  import IORedis from 'ioredis';
12
+ import { existsSync } from 'fs';
12
13
  import { getAdapter } from './adapters/registry.js';
13
14
  import { sendToThread } from './discord.js';
14
15
  import { isAway } from './features/brb.js';
15
- import { updateSessionId } from './db.js';
16
+ import { updateSessionId, getProject, getDefaultProject } from './db.js';
16
17
  import type { ClaudeJob } from './queue.js';
17
18
  import { debugLog, debugBlock } from './debug.js';
18
19
 
19
20
  const log = (msg: string) => process.stdout.write(`[worker] ${msg}\n`);
20
21
 
22
+ /** Resolve default working directory: default project > env > cwd. */
23
+ function getDefaultWorkingDir(): string {
24
+ const defaultProject = getDefaultProject();
25
+ if (defaultProject && existsSync(defaultProject.path)) {
26
+ return defaultProject.path;
27
+ }
28
+ const envDir = process.env.CLAUDE_WORKING_DIR;
29
+ if (envDir && existsSync(envDir)) return envDir;
30
+ if (envDir) log(`WARNING: CLAUDE_WORKING_DIR="${envDir}" does not exist, using cwd`);
31
+ return process.cwd();
32
+ }
33
+
21
34
  const connection = new IORedis({
22
35
  host: process.env.REDIS_HOST || 'localhost',
23
36
  port: parseInt(process.env.REDIS_PORT || '6379'),
@@ -27,9 +40,23 @@ const connection = new IORedis({
27
40
  const worker = new Worker<ClaudeJob>(
28
41
  'claude',
29
42
  async (job: Job<ClaudeJob>) => {
30
- const { prompt, threadId, sessionId, resume, username, workingDir, channelContext } = job.data;
43
+ const { prompt, threadId, sessionId, resume, username, workingDir: jobWorkingDir, projectName, channelContext } = job.data;
44
+
45
+ // Resolve working directory: project DB > job workingDir > default
46
+ let workingDir = jobWorkingDir;
47
+ if (projectName) {
48
+ const project = getProject(projectName);
49
+ if (project && existsSync(project.path)) {
50
+ workingDir = project.path;
51
+ } else if (project) {
52
+ log(`WARNING: Project "${projectName}" path not found: ${project.path}, using job workingDir`);
53
+ }
54
+ }
55
+ if (!workingDir) {
56
+ workingDir = getDefaultWorkingDir();
57
+ }
31
58
 
32
- log(`Processing job ${job.id} for ${username}`);
59
+ log(`Processing job ${job.id} for ${username}${projectName ? ` [project: ${projectName}]` : ''}`);
33
60
  log(`Session: ${sessionId}, Resume: ${resume}`);
34
61
 
35
62
  // Debug: Log full job details
@@ -186,7 +213,7 @@ debugBlock('worker', 'Worker Environment', {
186
213
  agentType: process.env.AGENT_TYPE || 'claude (default)',
187
214
  redisHost: process.env.REDIS_HOST || 'localhost',
188
215
  redisPort: process.env.REDIS_PORT || '6379',
189
- workingDir: process.env.CLAUDE_WORKING_DIR || process.cwd(),
216
+ workingDir: getDefaultWorkingDir(),
190
217
  tetherDebug: process.env.TETHER_DEBUG || 'false',
191
218
  });
192
219