@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/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @thesammykins/tether
|
|
3
|
+
*
|
|
4
|
+
* Discord bot that bridges messages to AI agent sessions.
|
|
5
|
+
* Supports Claude Code, OpenCode, and Codex CLI.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* bunx @thesammykins/tether start # Start bot + worker
|
|
9
|
+
* bunx @thesammykins/tether setup # Interactive setup
|
|
10
|
+
*
|
|
11
|
+
* Or programmatically:
|
|
12
|
+
* import { adapters, features } from '@thesammykins/tether'
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Adapters
|
|
16
|
+
export { ClaudeAdapter } from './src/adapters/claude.ts';
|
|
17
|
+
export { OpenCodeAdapter } from './src/adapters/opencode.ts';
|
|
18
|
+
export { CodexAdapter } from './src/adapters/codex.ts';
|
|
19
|
+
export { getAdapter } from './src/adapters/registry.ts';
|
|
20
|
+
export type { AgentAdapter, SpawnResult } from './src/adapters/types.ts';
|
|
21
|
+
|
|
22
|
+
// Features
|
|
23
|
+
export { acknowledgeMessage } from './src/features/ack.ts';
|
|
24
|
+
export { getChannelContext } from './src/features/channel-context.ts';
|
|
25
|
+
export { generateThreadName } from './src/features/thread-naming.ts';
|
|
26
|
+
export { checkSessionLimits } from './src/features/session-limits.ts';
|
|
27
|
+
export { handlePauseResume, isThreadPausedExport } from './src/features/pause-resume.ts';
|
|
28
|
+
export { isAway, setBrb, setBack, isBrbMessage, isBackMessage } from './src/features/brb.ts';
|
|
29
|
+
|
|
30
|
+
// Infrastructure
|
|
31
|
+
export { claudeQueue } from './src/queue.ts';
|
|
32
|
+
export { db, getChannelConfigCached, setChannelConfig } from './src/db.ts';
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thesammykins/tether",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Discord bot that bridges messages to AI agent sessions (Claude, OpenCode, Codex)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "thesammykins",
|
|
7
|
+
"homepage": "https://github.com/thesammykins/tether#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/thesammykins/tether"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/thesammykins/tether/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"discord",
|
|
17
|
+
"bot",
|
|
18
|
+
"ai",
|
|
19
|
+
"claude",
|
|
20
|
+
"opencode",
|
|
21
|
+
"codex",
|
|
22
|
+
"agent",
|
|
23
|
+
"bridge",
|
|
24
|
+
"bun"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"type": "module",
|
|
30
|
+
"module": "index.ts",
|
|
31
|
+
"main": "index.ts",
|
|
32
|
+
"bin": {
|
|
33
|
+
"tether": "bin/tether.ts"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src/",
|
|
37
|
+
"bin/tether.ts",
|
|
38
|
+
"index.ts",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
".env.example"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"start": "bun run bin/tether.ts start",
|
|
45
|
+
"bot": "bun run src/bot.ts",
|
|
46
|
+
"worker": "bun run src/worker.ts",
|
|
47
|
+
"setup": "bun run bin/tether.ts setup",
|
|
48
|
+
"test": "bun test"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"bullmq": "^5.67.1",
|
|
52
|
+
"discord.js": "^14.25.1",
|
|
53
|
+
"ioredis": "^5.9.2"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/bun": "^1.3.9"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"typescript": "^5"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"bun": ">=1.1.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude CLI Adapter
|
|
5
|
+
*
|
|
6
|
+
* Wraps the Claude Code CLI with proper session handling and output parsing.
|
|
7
|
+
*
|
|
8
|
+
* Key flags:
|
|
9
|
+
* - `--print`: Non-interactive mode, returns output
|
|
10
|
+
* - `--session-id UUID`: Set session ID for new sessions
|
|
11
|
+
* - `--resume UUID`: Resume an existing session (for follow-ups)
|
|
12
|
+
* - `--append-system-prompt`: Inject context that survives compaction
|
|
13
|
+
* - `-p "prompt"`: The actual prompt to send
|
|
14
|
+
* - `--output-format json`: Structured output (if supported)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TIMEZONE = process.env.TZ || 'UTC';
|
|
18
|
+
|
|
19
|
+
function getDatetimeContext(): string {
|
|
20
|
+
const now = new Date();
|
|
21
|
+
return now.toLocaleString('en-US', {
|
|
22
|
+
weekday: 'long',
|
|
23
|
+
year: 'numeric',
|
|
24
|
+
month: 'long',
|
|
25
|
+
day: 'numeric',
|
|
26
|
+
hour: 'numeric',
|
|
27
|
+
minute: '2-digit',
|
|
28
|
+
hour12: true,
|
|
29
|
+
timeZone: TIMEZONE,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ClaudeAdapter implements AgentAdapter {
|
|
34
|
+
readonly name = 'claude';
|
|
35
|
+
|
|
36
|
+
async spawn(options: SpawnOptions): Promise<SpawnResult> {
|
|
37
|
+
const { prompt, sessionId, resume, systemPrompt, workingDir } = options;
|
|
38
|
+
|
|
39
|
+
const cwd = workingDir || process.env.CLAUDE_WORKING_DIR || process.cwd();
|
|
40
|
+
|
|
41
|
+
// Build CLI arguments
|
|
42
|
+
const args = ['claude'];
|
|
43
|
+
|
|
44
|
+
// Non-interactive mode
|
|
45
|
+
args.push('--print');
|
|
46
|
+
|
|
47
|
+
// Output format
|
|
48
|
+
args.push('--output-format', 'json');
|
|
49
|
+
|
|
50
|
+
// Session handling
|
|
51
|
+
if (resume) {
|
|
52
|
+
// Resume existing session for follow-up messages
|
|
53
|
+
args.push('--resume', sessionId);
|
|
54
|
+
} else {
|
|
55
|
+
// New session - set the ID upfront
|
|
56
|
+
args.push('--session-id', sessionId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Inject datetime context (survives session compaction)
|
|
60
|
+
const datetimeContext = `Current date/time: ${getDatetimeContext()}`;
|
|
61
|
+
const fullSystemPrompt = systemPrompt
|
|
62
|
+
? `${datetimeContext}\n\n${systemPrompt}`
|
|
63
|
+
: datetimeContext;
|
|
64
|
+
|
|
65
|
+
args.push('--append-system-prompt', fullSystemPrompt);
|
|
66
|
+
|
|
67
|
+
// The actual prompt
|
|
68
|
+
args.push('-p', prompt);
|
|
69
|
+
|
|
70
|
+
// Spawn the process
|
|
71
|
+
const proc = Bun.spawn(args, {
|
|
72
|
+
cwd,
|
|
73
|
+
env: {
|
|
74
|
+
...process.env,
|
|
75
|
+
TZ: TIMEZONE,
|
|
76
|
+
},
|
|
77
|
+
stdout: 'pipe',
|
|
78
|
+
stderr: 'pipe',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Collect output
|
|
82
|
+
const stdout = await new Response(proc.stdout).text();
|
|
83
|
+
const stderr = await new Response(proc.stderr).text();
|
|
84
|
+
|
|
85
|
+
// Wait for process to exit
|
|
86
|
+
const exitCode = await proc.exited;
|
|
87
|
+
|
|
88
|
+
if (exitCode !== 0) {
|
|
89
|
+
throw new Error(`Claude CLI failed (exit ${exitCode}): ${stderr || 'Unknown error'}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Parse JSON output or fall back to raw text
|
|
93
|
+
let output = stdout.trim();
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(stdout);
|
|
96
|
+
output = parsed.response || parsed.output || stdout.trim();
|
|
97
|
+
} catch {
|
|
98
|
+
// Not JSON, use raw output
|
|
99
|
+
output = stdout.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
output,
|
|
104
|
+
sessionId,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Codex CLI Adapter
|
|
5
|
+
*
|
|
6
|
+
* Wraps the Codex CLI with session handling and JSON output parsing.
|
|
7
|
+
*
|
|
8
|
+
* Key commands:
|
|
9
|
+
* - `codex exec "<prompt>"`: Execute a new prompt
|
|
10
|
+
* - `codex exec resume <sessionId> "<prompt>"`: Resume existing session
|
|
11
|
+
* - `--json`: Structured output
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export class CodexAdapter implements AgentAdapter {
|
|
15
|
+
readonly name = 'codex';
|
|
16
|
+
|
|
17
|
+
async spawn(options: SpawnOptions): Promise<SpawnResult> {
|
|
18
|
+
const { prompt, sessionId, resume, workingDir } = options;
|
|
19
|
+
|
|
20
|
+
const args = ['codex', 'exec'];
|
|
21
|
+
|
|
22
|
+
// Session handling
|
|
23
|
+
if (resume) {
|
|
24
|
+
// Resume existing session
|
|
25
|
+
args.push('resume', sessionId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// JSON output format
|
|
29
|
+
args.push('--json');
|
|
30
|
+
|
|
31
|
+
// The prompt (always last)
|
|
32
|
+
args.push(prompt);
|
|
33
|
+
|
|
34
|
+
// Spawn the process
|
|
35
|
+
const proc = Bun.spawn(args, {
|
|
36
|
+
cwd: workingDir || process.cwd(),
|
|
37
|
+
env: process.env,
|
|
38
|
+
stdout: 'pipe',
|
|
39
|
+
stderr: 'pipe',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Collect output
|
|
43
|
+
const stdout = await new Response(proc.stdout).text();
|
|
44
|
+
const stderr = await new Response(proc.stderr).text();
|
|
45
|
+
|
|
46
|
+
// Wait for process to exit
|
|
47
|
+
const exitCode = await proc.exited;
|
|
48
|
+
|
|
49
|
+
if (exitCode !== 0) {
|
|
50
|
+
throw new Error(`Codex CLI failed (exit ${exitCode}): ${stderr || 'Unknown error'}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Parse JSON output
|
|
54
|
+
let output = stdout.trim();
|
|
55
|
+
let finalSessionId = sessionId;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(stdout);
|
|
59
|
+
output = parsed.output || parsed.response || parsed.result || stdout.trim();
|
|
60
|
+
|
|
61
|
+
// Extract session ID from output (Codex auto-assigns)
|
|
62
|
+
if (parsed.sessionId) {
|
|
63
|
+
finalSessionId = parsed.sessionId;
|
|
64
|
+
} else if (parsed.session_id) {
|
|
65
|
+
finalSessionId = parsed.session_id;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Not JSON, use raw output
|
|
69
|
+
output = stdout.trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
output,
|
|
74
|
+
sessionId: finalSessionId,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OpenCode CLI Adapter
|
|
5
|
+
*
|
|
6
|
+
* Wraps the OpenCode CLI with session handling and JSON output parsing.
|
|
7
|
+
*
|
|
8
|
+
* Key flags:
|
|
9
|
+
* - `run "<prompt>"`: Execute a prompt
|
|
10
|
+
* - `--session <id>` or `--continue`: Resume existing session
|
|
11
|
+
* - `--format json`: Structured output
|
|
12
|
+
* - `--cwd <path>`: Set working directory
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export class OpenCodeAdapter implements AgentAdapter {
|
|
16
|
+
readonly name = 'opencode';
|
|
17
|
+
|
|
18
|
+
async spawn(options: SpawnOptions): Promise<SpawnResult> {
|
|
19
|
+
const { prompt, sessionId, resume, workingDir } = options;
|
|
20
|
+
|
|
21
|
+
const args = ['opencode', 'run'];
|
|
22
|
+
|
|
23
|
+
// Format as JSON for structured output
|
|
24
|
+
args.push('--format', 'json');
|
|
25
|
+
|
|
26
|
+
// Session handling
|
|
27
|
+
if (resume) {
|
|
28
|
+
// Resume existing session
|
|
29
|
+
args.push('--session', sessionId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Working directory
|
|
33
|
+
if (workingDir) {
|
|
34
|
+
args.push('--cwd', workingDir);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The prompt (always last)
|
|
38
|
+
args.push(prompt);
|
|
39
|
+
|
|
40
|
+
// Spawn the process
|
|
41
|
+
const proc = Bun.spawn(args, {
|
|
42
|
+
cwd: workingDir || process.cwd(),
|
|
43
|
+
env: process.env,
|
|
44
|
+
stdout: 'pipe',
|
|
45
|
+
stderr: 'pipe',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Collect output
|
|
49
|
+
const stdout = await new Response(proc.stdout).text();
|
|
50
|
+
const stderr = await new Response(proc.stderr).text();
|
|
51
|
+
|
|
52
|
+
// Wait for process to exit
|
|
53
|
+
const exitCode = await proc.exited;
|
|
54
|
+
|
|
55
|
+
if (exitCode !== 0) {
|
|
56
|
+
throw new Error(`OpenCode CLI failed (exit ${exitCode}): ${stderr || 'Unknown error'}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Parse JSON output
|
|
60
|
+
let output = stdout.trim();
|
|
61
|
+
let finalSessionId = sessionId;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(stdout);
|
|
65
|
+
output = parsed.output || parsed.response || parsed.result || stdout.trim();
|
|
66
|
+
|
|
67
|
+
// Extract session ID from output if not resuming
|
|
68
|
+
if (!resume && parsed.sessionId) {
|
|
69
|
+
finalSessionId = parsed.sessionId;
|
|
70
|
+
} else if (!resume && parsed.session_id) {
|
|
71
|
+
finalSessionId = parsed.session_id;
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Not JSON, use raw output
|
|
75
|
+
output = stdout.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
output,
|
|
80
|
+
sessionId: finalSessionId,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentAdapter } from './types.js';
|
|
2
|
+
import { ClaudeAdapter } from './claude.js';
|
|
3
|
+
import { OpenCodeAdapter } from './opencode.js';
|
|
4
|
+
import { CodexAdapter } from './codex.js';
|
|
5
|
+
|
|
6
|
+
export function getAdapter(type?: string): AgentAdapter {
|
|
7
|
+
const adapterType = type || process.env.AGENT_TYPE || 'claude';
|
|
8
|
+
|
|
9
|
+
switch (adapterType.toLowerCase()) {
|
|
10
|
+
case 'claude':
|
|
11
|
+
return new ClaudeAdapter();
|
|
12
|
+
case 'opencode':
|
|
13
|
+
return new OpenCodeAdapter();
|
|
14
|
+
case 'codex':
|
|
15
|
+
return new CodexAdapter();
|
|
16
|
+
default:
|
|
17
|
+
throw new Error(`Unknown adapter type: ${adapterType}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getSupportedAdapters(): string[] {
|
|
22
|
+
return ['claude', 'opencode', 'codex'];
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SpawnOptions {
|
|
2
|
+
prompt: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
resume: boolean;
|
|
5
|
+
systemPrompt?: string;
|
|
6
|
+
workingDir?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SpawnResult {
|
|
10
|
+
output: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AgentAdapter {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
spawn(options: SpawnOptions): Promise<SpawnResult>;
|
|
17
|
+
}
|