@thesammykins/tether 1.0.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/spawner.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Spawner - The Claude CLI integration
3
+ *
4
+ * THIS IS THE CORE OF THE SYSTEM.
5
+ *
6
+ * Key flags:
7
+ * - `--print`: Non-interactive mode, returns output
8
+ * - `--session-id UUID`: Set session ID for new sessions
9
+ * - `--resume UUID`: Resume an existing session (for follow-ups)
10
+ * - `--append-system-prompt`: Inject context that survives compaction
11
+ * - `-p "prompt"`: The actual prompt to send
12
+ */
13
+
14
+ const log = (msg: string) => process.stdout.write(`[spawner] ${msg}\n`);
15
+
16
+ // Timezone for datetime injection (set via TZ env var)
17
+ const TIMEZONE = process.env.TZ || 'UTC';
18
+
19
+ interface SpawnOptions {
20
+ prompt: string;
21
+ sessionId: string;
22
+ resume: boolean;
23
+ systemPrompt?: string;
24
+ workingDir?: string;
25
+ }
26
+
27
+ /**
28
+ * Get current datetime in user's timezone
29
+ * Claude Code doesn't know the time - we inject it
30
+ */
31
+ function getDatetimeContext(): string {
32
+ const now = new Date();
33
+ return now.toLocaleString('en-US', {
34
+ weekday: 'long',
35
+ year: 'numeric',
36
+ month: 'long',
37
+ day: 'numeric',
38
+ hour: 'numeric',
39
+ minute: '2-digit',
40
+ hour12: true,
41
+ timeZone: TIMEZONE,
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Spawn Claude CLI and return the response
47
+ */
48
+ export async function spawnClaude(options: SpawnOptions): Promise<string> {
49
+ const { prompt, sessionId, resume, systemPrompt, workingDir } = options;
50
+
51
+ const cwd = workingDir || process.env.CLAUDE_WORKING_DIR || process.cwd();
52
+ log(`Spawning Claude - Session: ${sessionId}, Resume: ${resume}`);
53
+ log(`Working directory: ${cwd}`);
54
+
55
+ // Build CLI arguments
56
+ const args = ['claude'];
57
+
58
+ // Non-interactive mode
59
+ args.push('--print');
60
+
61
+ // Session handling
62
+ if (resume) {
63
+ // Resume existing session for follow-up messages
64
+ args.push('--resume', sessionId);
65
+ } else {
66
+ // New session - set the ID upfront
67
+ args.push('--session-id', sessionId);
68
+ }
69
+
70
+ // Inject datetime context (survives session compaction)
71
+ const datetimeContext = `Current date/time: ${getDatetimeContext()}`;
72
+ const fullSystemPrompt = systemPrompt
73
+ ? `${datetimeContext}\n\n${systemPrompt}`
74
+ : datetimeContext;
75
+
76
+ args.push('--append-system-prompt', fullSystemPrompt);
77
+
78
+ // The actual prompt
79
+ args.push('-p', prompt);
80
+
81
+ log(`Command: ${args.join(' ').slice(0, 100)}...`);
82
+
83
+ // Spawn the process
84
+ const proc = Bun.spawn(args, {
85
+ cwd,
86
+ env: {
87
+ ...process.env,
88
+ TZ: TIMEZONE,
89
+ },
90
+ stdout: 'pipe',
91
+ stderr: 'pipe',
92
+ });
93
+
94
+ // Collect output
95
+ const stdout = await new Response(proc.stdout).text();
96
+ const stderr = await new Response(proc.stderr).text();
97
+
98
+ // Wait for process to exit
99
+ const exitCode = await proc.exited;
100
+
101
+ if (exitCode !== 0) {
102
+ log(`Claude exited with code ${exitCode}`);
103
+ log(`stderr: ${stderr}`);
104
+ throw new Error(`Claude failed: ${stderr || 'Unknown error'}`);
105
+ }
106
+
107
+ log(`Claude responded (${stdout.length} chars)`);
108
+
109
+ return stdout.trim();
110
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Worker - Processes Claude jobs from the queue
3
+ *
4
+ * This is where the magic happens:
5
+ * 1. Pulls jobs from the queue
6
+ * 2. Spawns the configured agent adapter (Claude/OpenCode/Codex)
7
+ * 3. Posts the response back to Discord
8
+ */
9
+
10
+ import { Worker, Job } from 'bullmq';
11
+ import IORedis from 'ioredis';
12
+ import { getAdapter } from './adapters/registry.js';
13
+ import { sendToThread } from './discord.js';
14
+ import { isAway } from './features/brb.js';
15
+ import type { ClaudeJob } from './queue.js';
16
+
17
+ const log = (msg: string) => process.stdout.write(`[worker] ${msg}\n`);
18
+
19
+ const connection = new IORedis({
20
+ host: process.env.REDIS_HOST || 'localhost',
21
+ port: parseInt(process.env.REDIS_PORT || '6379'),
22
+ maxRetriesPerRequest: null,
23
+ });
24
+
25
+ const worker = new Worker<ClaudeJob>(
26
+ 'claude',
27
+ async (job: Job<ClaudeJob>) => {
28
+ const { prompt, threadId, sessionId, resume, username, workingDir } = job.data;
29
+
30
+ log(`Processing job ${job.id} for ${username}`);
31
+ log(`Session: ${sessionId}, Resume: ${resume}`);
32
+
33
+ try {
34
+ // Get the configured adapter and spawn
35
+ const adapter = getAdapter();
36
+ log(`Using adapter: ${adapter.name}`);
37
+
38
+ // If user is away (BRB mode), prepend guidance to use tether ask CLI
39
+ let effectivePrompt = prompt;
40
+ if (isAway(threadId)) {
41
+ const brbPrefix = [
42
+ '[IMPORTANT: The user is currently away from this conversation.',
43
+ 'If you need to ask them a question or get their input, DO NOT use your built-in question/approval tools.',
44
+ 'Instead, use the tether CLI:',
45
+ '',
46
+ ` tether ask ${threadId} "Your question here" --option "Option A" --option "Option B"`,
47
+ '',
48
+ 'This will send interactive buttons to Discord and block until the user responds.',
49
+ 'The selected option will be printed to stdout.',
50
+ `Thread ID for this conversation: ${threadId}]`,
51
+ ].join('\n');
52
+ effectivePrompt = `${brbPrefix}\n\n${prompt}`;
53
+ log(`BRB mode active for thread ${threadId} — injected tether ask guidance`);
54
+ }
55
+
56
+ const result = await adapter.spawn({
57
+ prompt: effectivePrompt,
58
+ sessionId,
59
+ resume,
60
+ workingDir,
61
+ });
62
+
63
+ // Send response to Discord thread
64
+ await sendToThread(threadId, result.output);
65
+
66
+ log(`Job ${job.id} completed`);
67
+ return { success: true, responseLength: result.output.length };
68
+
69
+ } catch (error) {
70
+ log(`Job ${job.id} failed: ${error}`);
71
+
72
+ // Send error message to thread
73
+ await sendToThread(
74
+ threadId,
75
+ `Something went wrong. Try again?\n\`\`\`${error}\`\`\``
76
+ );
77
+
78
+ throw error; // Re-throw for BullMQ retry logic
79
+ }
80
+ },
81
+ {
82
+ connection,
83
+ concurrency: 2, // Process up to 2 jobs at once
84
+ }
85
+ );
86
+
87
+ worker.on('completed', (job) => {
88
+ log(`Job ${job?.id} completed`);
89
+ });
90
+
91
+ worker.on('failed', (job, err) => {
92
+ log(`Job ${job?.id} failed: ${err.message}`);
93
+ });
94
+
95
+ log('Worker started, waiting for jobs...');
96
+
97
+ export { worker };