@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/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/bin/tether.ts +1010 -0
- package/index.ts +32 -0
- package/package.json +64 -0
- package/src/adapters/claude.ts +107 -0
- package/src/adapters/codex.ts +77 -0
- package/src/adapters/opencode.ts +83 -0
- package/src/adapters/registry.ts +23 -0
- package/src/adapters/types.ts +17 -0
- package/src/api.ts +494 -0
- package/src/bot.ts +653 -0
- package/src/db.ts +123 -0
- package/src/discord.ts +80 -0
- package/src/features/ack.ts +10 -0
- package/src/features/brb.ts +79 -0
- package/src/features/channel-context.ts +23 -0
- package/src/features/pause-resume.ts +86 -0
- package/src/features/session-limits.ts +48 -0
- package/src/features/thread-naming.ts +33 -0
- package/src/middleware/allowlist.ts +64 -0
- package/src/middleware/rate-limiter.ts +46 -0
- package/src/queue.ts +43 -0
- package/src/spawner.ts +110 -0
- package/src/worker.ts +97 -0
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 };
|