@thesammykins/tether 1.3.0 → 1.5.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/bin/tether.ts +6 -1
- package/docs/agents.md +21 -0
- package/docs/architecture.md +14 -0
- package/docs/configuration.md +73 -8
- package/docs/discord-setup.md +29 -2
- package/docs/troubleshooting.md +2 -1
- package/package.json +1 -1
- package/src/adapters/claude.ts +211 -6
- package/src/adapters/codex.ts +47 -1
- package/src/adapters/opencode.ts +47 -1
- package/src/adapters/resolve-binary.ts +96 -0
- package/src/bot.ts +76 -14
- package/src/config.ts +14 -2
- package/src/features/sessions.ts +201 -0
package/bin/tether.ts
CHANGED
|
@@ -1010,7 +1010,12 @@ async function configCommand() {
|
|
|
1010
1010
|
console.error('Password cannot be empty');
|
|
1011
1011
|
process.exit(1);
|
|
1012
1012
|
}
|
|
1013
|
-
|
|
1013
|
+
try {
|
|
1014
|
+
writeSecret(key, value, pw);
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
console.error(err instanceof Error ? err.message : 'Failed to save secret');
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1014
1019
|
console.log(`✔ Secret "${key}" saved (encrypted)`);
|
|
1015
1020
|
} else {
|
|
1016
1021
|
if (value === undefined) {
|
package/docs/agents.md
CHANGED
|
@@ -52,6 +52,13 @@ claude --print --output-format json --session-id <id> -p "<prompt>"
|
|
|
52
52
|
claude --print --output-format json --resume <id> -p "<prompt>"
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
If the `claude` binary is not on PATH (common for service/daemon installs),
|
|
56
|
+
set an explicit path override:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
tether config set CLAUDE_BIN /full/path/to/claude
|
|
60
|
+
```
|
|
61
|
+
|
|
55
62
|
Key flags:
|
|
56
63
|
- `--print` — Non-interactive mode, returns output to stdout
|
|
57
64
|
- `--session-id` / `--resume` — Session persistence across messages in the same thread
|
|
@@ -109,6 +116,13 @@ opencode run --format json "<prompt>"
|
|
|
109
116
|
opencode run --format json --session <id> "<prompt>"
|
|
110
117
|
```
|
|
111
118
|
|
|
119
|
+
If the `opencode` binary is not on PATH (common for service/daemon installs),
|
|
120
|
+
set an explicit path override:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
tether config set OPENCODE_BIN /full/path/to/opencode
|
|
124
|
+
```
|
|
125
|
+
|
|
112
126
|
Key flags:
|
|
113
127
|
- `run` — Execute a prompt
|
|
114
128
|
- `--format json` — Structured output
|
|
@@ -152,6 +166,13 @@ codex exec --json "<prompt>"
|
|
|
152
166
|
codex exec resume <sessionId> --json "<prompt>"
|
|
153
167
|
```
|
|
154
168
|
|
|
169
|
+
If the `codex` binary is not on PATH (common for service/daemon installs),
|
|
170
|
+
set an explicit path override:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
tether config set CODEX_BIN /full/path/to/codex
|
|
174
|
+
```
|
|
175
|
+
|
|
155
176
|
Key flags:
|
|
156
177
|
- `exec` — Execute a prompt
|
|
157
178
|
- `--json` — Structured output
|
package/docs/architecture.md
CHANGED
|
@@ -37,6 +37,20 @@ The bot handles Discord events, the queue provides durability and backpressure,
|
|
|
37
37
|
6. Adapter runs the CLI (`claude`/`opencode`/`codex`) with the prompt
|
|
38
38
|
7. Worker posts the agent's response back to the Discord thread
|
|
39
39
|
|
|
40
|
+
### Forum Channels
|
|
41
|
+
|
|
42
|
+
When `FORUM_SESSIONS=true` and `FORUM_CHANNEL_ID` is set:
|
|
43
|
+
|
|
44
|
+
1. User `@mentions` the bot in any channel
|
|
45
|
+
2. Bot runs the middleware pipeline (allowlist → rate limiter → pause check)
|
|
46
|
+
3. Bot creates a **forum post** in the configured forum channel (instead of a thread)
|
|
47
|
+
4. Bot replies in the original channel with a link to the forum post
|
|
48
|
+
5. Bot adds a job to the BullMQ queue
|
|
49
|
+
6. Worker posts the response in the forum post
|
|
50
|
+
7. Follow-up messages in the forum post continue the same session
|
|
51
|
+
|
|
52
|
+
Forum threads use the same `thread_id → session_id` DB mapping as regular threads — no schema differences.
|
|
53
|
+
|
|
40
54
|
### Direct Messages
|
|
41
55
|
|
|
42
56
|
1. User sends a message to the bot in DMs (no `@mention` needed)
|
package/docs/configuration.md
CHANGED
|
@@ -73,6 +73,9 @@ tether config list
|
|
|
73
73
|
|-----|---------|-------------|
|
|
74
74
|
| `AGENT_TYPE` | `claude` | Agent backend: `claude`, `opencode`, `codex` |
|
|
75
75
|
| `CLAUDE_WORKING_DIR` | cwd | Default working directory for agent sessions |
|
|
76
|
+
| `CLAUDE_BIN` | (empty) | Override path to Claude CLI binary |
|
|
77
|
+
| `OPENCODE_BIN` | (empty) | Override path to OpenCode CLI binary |
|
|
78
|
+
| `CODEX_BIN` | (empty) | Override path to Codex CLI binary |
|
|
76
79
|
|
|
77
80
|
### Server
|
|
78
81
|
|
|
@@ -88,13 +91,67 @@ tether config list
|
|
|
88
91
|
| `REDIS_HOST` | `localhost` | Redis host |
|
|
89
92
|
| `REDIS_PORT` | `6379` | Redis port |
|
|
90
93
|
|
|
91
|
-
### Security
|
|
94
|
+
### Security & Access Control
|
|
95
|
+
|
|
96
|
+
Tether supports restricting who can interact with the bot using allowlists. If none are configured, the bot responds to all users.
|
|
97
|
+
|
|
98
|
+
#### User Allowlist
|
|
99
|
+
|
|
100
|
+
Restrict the bot to only respond to specific Discord users:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Set allowed users (comma-separated Discord user IDs)
|
|
104
|
+
tether config set ALLOWED_USERS 123456789012345678,987654321098765432
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**How to find your Discord user ID:**
|
|
108
|
+
|
|
109
|
+
1. Enable **Developer Mode** in Discord: Settings → App Settings → Advanced → Developer Mode
|
|
110
|
+
2. Right-click on your username (or any user) → **Copy ID**
|
|
111
|
+
|
|
112
|
+
When `ALLOWED_USERS` is set, only users in the list can interact with the bot. This works in both guild channels and DMs.
|
|
113
|
+
|
|
114
|
+
#### Role and Channel Allowlists
|
|
115
|
+
|
|
116
|
+
For guild (server) deployments, you can also restrict by role or channel:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
# Only allow users with specific roles (comma-separated role IDs)
|
|
120
|
+
tether config set ALLOWED_ROLES 111111111111111111,222222222222222222
|
|
121
|
+
|
|
122
|
+
# Only allow bot usage in specific channels (comma-separated channel IDs)
|
|
123
|
+
tether config set ALLOWED_CHANNELS 333333333333333333,444444444444444444
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**How these work together:**
|
|
127
|
+
- If `ALLOWED_CHANNELS` is set, messages must be in an allowed channel (or its threads)
|
|
128
|
+
- If `ALLOWED_USERS` or `ALLOWED_ROLES` is set, the user must match at least one:
|
|
129
|
+
- Be in the `ALLOWED_USERS` list, OR
|
|
130
|
+
- Have a role in the `ALLOWED_ROLES` list
|
|
131
|
+
- Role and channel allowlists only apply in guilds (not DMs)
|
|
132
|
+
- If no allowlists are configured, the bot responds to everyone
|
|
133
|
+
|
|
134
|
+
**Examples:**
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# Only respond to yourself
|
|
138
|
+
tether config set ALLOWED_USERS 123456789012345678
|
|
139
|
+
|
|
140
|
+
# Only respond in a specific channel
|
|
141
|
+
tether config set ALLOWED_CHANNELS 987654321098765432
|
|
142
|
+
|
|
143
|
+
# Only respond to admins (role ID)
|
|
144
|
+
tether config set ALLOWED_ROLES 555555555555555555
|
|
145
|
+
|
|
146
|
+
# Combine: only respond to specific users in specific channels
|
|
147
|
+
tether config set ALLOWED_USERS 123456789012345678
|
|
148
|
+
tether config set ALLOWED_CHANNELS 987654321098765432
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
#### Directory Allowlist
|
|
92
152
|
|
|
93
153
|
| Key | Default | Description |
|
|
94
154
|
|-----|---------|-------------|
|
|
95
|
-
| `ALLOWED_USERS` | (empty = all) | Comma-separated Discord user IDs |
|
|
96
|
-
| `ALLOWED_ROLES` | (empty = all) | Comma-separated Discord role IDs (guild only) |
|
|
97
|
-
| `ALLOWED_CHANNELS` | (empty = all) | Comma-separated Discord channel IDs (guild only) |
|
|
98
155
|
| `CORD_ALLOWED_DIRS` | (empty = any) | Comma-separated allowed working directories |
|
|
99
156
|
|
|
100
157
|
### Limits
|
|
@@ -114,6 +171,17 @@ tether config list
|
|
|
114
171
|
| `FORUM_SESSIONS` | `false` | Use forum channel posts instead of text channel threads for sessions |
|
|
115
172
|
| `FORUM_CHANNEL_ID` | (empty) | Discord forum channel ID for session posts (required when `FORUM_SESSIONS=true`) |
|
|
116
173
|
|
|
174
|
+
#### Forum Sessions
|
|
175
|
+
|
|
176
|
+
By default, Tether creates threads in the channel where the bot is mentioned. When forum sessions are enabled, it creates forum posts in a dedicated forum channel instead — useful for keeping conversations organized and searchable.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
tether config set FORUM_SESSIONS true
|
|
180
|
+
tether config set FORUM_CHANNEL_ID 123456789012345678
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Both keys are required. See [Discord Setup — Forum Channels](discord-setup.md#6-use-forum-channels-optional) for the full setup guide.
|
|
184
|
+
|
|
117
185
|
### Database
|
|
118
186
|
|
|
119
187
|
| Key | Default | Description |
|
|
@@ -154,7 +222,4 @@ Paths outside the allowlist are rejected. If unset, any existing directory is al
|
|
|
154
222
|
|
|
155
223
|
## Finding Discord IDs
|
|
156
224
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
1. Enable **Developer Mode** in Discord: Settings → App Settings → Advanced → Developer Mode
|
|
160
|
-
2. Right-click a user, role, or channel → **Copy ID**
|
|
225
|
+
See the [Security & Access Control](#security--access-control) section above for instructions on finding user, role, and channel IDs.
|
package/docs/discord-setup.md
CHANGED
|
@@ -64,7 +64,34 @@ The bot needs these permissions in your server:
|
|
|
64
64
|
4. Copy the generated URL and open it in your browser
|
|
65
65
|
5. Select the server you want to add the bot to and click **Authorize**
|
|
66
66
|
|
|
67
|
-
## 6.
|
|
67
|
+
## 6. Use Forum Channels (Optional)
|
|
68
|
+
|
|
69
|
+
By default, Tether creates threads in the channel where the bot is mentioned. If you prefer organized, searchable forum posts instead:
|
|
70
|
+
|
|
71
|
+
1. Create a **Forum Channel** in your Discord server (Server Settings → Channels → Create Channel → Forum)
|
|
72
|
+
2. Configure Tether to use it:
|
|
73
|
+
```bash
|
|
74
|
+
tether config set FORUM_SESSIONS true
|
|
75
|
+
tether config set FORUM_CHANNEL_ID 123456789012345678
|
|
76
|
+
```
|
|
77
|
+
3. Restart Tether (`tether stop && tether start`)
|
|
78
|
+
|
|
79
|
+
### Forum Behavior
|
|
80
|
+
|
|
81
|
+
- When someone `@mentions` the bot in any channel, a new **forum post** is created in your forum channel instead of a thread
|
|
82
|
+
- The bot replies in the original channel with a link to the forum post
|
|
83
|
+
- Follow-up messages in the forum post continue the same session — no `@mention` needed
|
|
84
|
+
- Session reuse, pause/resume, BRB, and ✅ completion all work the same as regular threads
|
|
85
|
+
- The forum post title is auto-generated from the first prompt
|
|
86
|
+
|
|
87
|
+
### Finding the Forum Channel ID
|
|
88
|
+
|
|
89
|
+
1. Enable **Developer Mode** (see [Finding Discord IDs](#finding-discord-ids) below)
|
|
90
|
+
2. Right-click the forum channel → **Copy ID**
|
|
91
|
+
|
|
92
|
+
> **Note:** The bot needs **Send Messages**, **Create Posts**, and **Send Messages in Threads** permissions in the forum channel.
|
|
93
|
+
|
|
94
|
+
## 7. Enable DMs (Optional)
|
|
68
95
|
|
|
69
96
|
If you want users to DM the bot directly:
|
|
70
97
|
|
|
@@ -82,7 +109,7 @@ If you want users to DM the bot directly:
|
|
|
82
109
|
- Send `!reset` in DMs to start a fresh session
|
|
83
110
|
- Only `ALLOWED_USERS` is checked for DMs (roles and channels don't apply)
|
|
84
111
|
|
|
85
|
-
##
|
|
112
|
+
## 8. Start and Test
|
|
86
113
|
|
|
87
114
|
```bash
|
|
88
115
|
# Start Redis (if not already running)
|
package/docs/troubleshooting.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
| `DisallowedIntents` error | Enable the required intents in Developer Portal → Bot tab (see [Discord Setup](discord-setup.md#3-configure-privileged-intents)) |
|
|
10
10
|
| Bot doesn't receive DMs | Set `ENABLE_DMS=true`: `tether config set ENABLE_DMS true` |
|
|
11
11
|
| "Rate limit exceeded" | Adjust `RATE_LIMIT_REQUESTS` / `RATE_LIMIT_WINDOW_MS` (see [Configuration](configuration.md#limits)) |
|
|
12
|
-
| Agent command not found | Ensure `claude`/`opencode`/`codex` is installed and on PATH (see [Agent Setup](agents.md)) |
|
|
12
|
+
| Agent command not found | Ensure `claude`/`opencode`/`codex` is installed and on PATH. If running as a service, set `CLAUDE_BIN` / `OPENCODE_BIN` / `CODEX_BIN` to an absolute path (see [Agent Setup](agents.md)) |
|
|
13
13
|
| Redis connection refused | Start Redis: `redis-server` (or `brew services start redis` on macOS) |
|
|
14
14
|
| Bot can't create threads | Check bot has **Create Public Threads** permission in your server |
|
|
15
15
|
| `tether start` hangs / does nothing | Check for a stale PID file: `rm -f .tether.pid` then try again |
|
|
@@ -55,6 +55,7 @@ tail -f /tmp/tether.log
|
|
|
55
55
|
| Problem | Solution |
|
|
56
56
|
|---------|----------|
|
|
57
57
|
| `opencode: command not found` | Install: `curl -fsSL https://opencode.ai/install \| bash` |
|
|
58
|
+
| `opencode` installed but not found | Set `OPENCODE_BIN` to the full path (e.g. `~/.opencode/bin/opencode`) and restart Tether |
|
|
58
59
|
| API key not set | Set your provider key: `export ANTHROPIC_API_KEY=sk-ant-...` or `export OPENAI_API_KEY=sk-...` |
|
|
59
60
|
|
|
60
61
|
### Codex
|
package/package.json
CHANGED
package/src/adapters/claude.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
|
|
2
|
+
import { getHomeCandidate, getSystemBinaryCandidates, resolveBinary, resolveNpmGlobalBinary } from './resolve-binary.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Claude CLI Adapter
|
|
@@ -9,12 +10,128 @@ import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
|
|
|
9
10
|
* - `--print`: Non-interactive mode, returns output
|
|
10
11
|
* - `--session-id UUID`: Set session ID for new sessions
|
|
11
12
|
* - `--resume UUID`: Resume an existing session (for follow-ups)
|
|
13
|
+
* - `--continue` / `-c`: Resume latest session in directory (fallback)
|
|
12
14
|
* - `--append-system-prompt`: Inject context that survives compaction
|
|
13
15
|
* - `-p "prompt"`: The actual prompt to send
|
|
14
16
|
* - `--output-format json`: Structured output (if supported)
|
|
17
|
+
*
|
|
18
|
+
* Known Issues:
|
|
19
|
+
* - GitHub Issue #5012: `--resume` was broken in v1.0.67
|
|
20
|
+
* - Sessions are directory-scoped; must resume from same cwd
|
|
21
|
+
* - `--continue` is preferred fallback when `--resume` fails
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
const TIMEZONE = process.env.TZ || 'UTC';
|
|
25
|
+
const KNOWN_BUGGY_VERSIONS = ['1.0.67'];
|
|
26
|
+
|
|
27
|
+
// Cache resolved binary path
|
|
28
|
+
let cachedBinaryPath: string | null = null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the Claude CLI binary path.
|
|
32
|
+
* Tries `which claude` (macOS/Linux) or `where.exe claude` (Windows),
|
|
33
|
+
* then falls back to checking `npx @anthropic-ai/claude-code` availability.
|
|
34
|
+
*/
|
|
35
|
+
async function getClaudeBinaryPath(): Promise<string> {
|
|
36
|
+
const envValue = process.env.CLAUDE_BIN;
|
|
37
|
+
if (envValue) {
|
|
38
|
+
if (cachedBinaryPath !== envValue) {
|
|
39
|
+
cachedBinaryPath = envValue;
|
|
40
|
+
console.log(`[claude] Binary resolved (env): ${envValue}`);
|
|
41
|
+
}
|
|
42
|
+
return cachedBinaryPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (cachedBinaryPath) {
|
|
46
|
+
return cachedBinaryPath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const resolved = await resolveBinary({
|
|
50
|
+
name: 'claude',
|
|
51
|
+
candidates: [
|
|
52
|
+
...getSystemBinaryCandidates('claude'),
|
|
53
|
+
getHomeCandidate('.claude', 'bin', 'claude'),
|
|
54
|
+
getHomeCandidate('.local', 'bin', 'claude'),
|
|
55
|
+
],
|
|
56
|
+
windowsCandidates: [
|
|
57
|
+
getHomeCandidate('.claude', 'bin', 'claude.exe'),
|
|
58
|
+
getHomeCandidate('.local', 'bin', 'claude.exe'),
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (resolved) {
|
|
63
|
+
cachedBinaryPath = resolved.path;
|
|
64
|
+
console.log(`[claude] Binary resolved (${resolved.source}): ${resolved.path}`);
|
|
65
|
+
return cachedBinaryPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const npmBinary = await resolveNpmGlobalBinary('claude');
|
|
69
|
+
if (npmBinary) {
|
|
70
|
+
cachedBinaryPath = npmBinary;
|
|
71
|
+
console.log(`[claude] Binary resolved (npm): ${npmBinary}`);
|
|
72
|
+
return cachedBinaryPath;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Try npx fallback
|
|
76
|
+
try {
|
|
77
|
+
const proc = Bun.spawn(['npx', '--version'], {
|
|
78
|
+
stdout: 'pipe',
|
|
79
|
+
stderr: 'pipe',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const exitCode = await proc.exited;
|
|
83
|
+
|
|
84
|
+
if (exitCode === 0) {
|
|
85
|
+
cachedBinaryPath = 'npx';
|
|
86
|
+
console.log('[claude] Binary not in PATH, will use: npx @anthropic-ai/claude-code');
|
|
87
|
+
return cachedBinaryPath;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Fall through to error
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error(
|
|
94
|
+
'Claude CLI not found. Install it or set CLAUDE_BIN to the binary path.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get the Claude CLI version.
|
|
100
|
+
* Runs `claude --version` and parses the output.
|
|
101
|
+
*/
|
|
102
|
+
async function getClaudeVersion(binaryPath: string): Promise<string> {
|
|
103
|
+
const args = binaryPath === 'npx'
|
|
104
|
+
? ['npx', '@anthropic-ai/claude-code', '--version']
|
|
105
|
+
: [binaryPath, '--version'];
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const proc = Bun.spawn(args, {
|
|
109
|
+
stdout: 'pipe',
|
|
110
|
+
stderr: 'pipe',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const stdout = await new Response(proc.stdout).text();
|
|
114
|
+
const exitCode = await proc.exited;
|
|
115
|
+
|
|
116
|
+
if (exitCode === 0) {
|
|
117
|
+
const version = stdout.trim();
|
|
118
|
+
console.log(`[claude] CLI version: ${version}`);
|
|
119
|
+
|
|
120
|
+
// Warn if known buggy version
|
|
121
|
+
if (KNOWN_BUGGY_VERSIONS.some((v) => version.includes(v))) {
|
|
122
|
+
console.warn(
|
|
123
|
+
`[claude] WARNING: Version ${version} has known issues with --resume (GitHub #5012)`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return version;
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn('[claude] Could not determine CLI version:', err);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return 'unknown';
|
|
134
|
+
}
|
|
18
135
|
|
|
19
136
|
function getDatetimeContext(): string {
|
|
20
137
|
const now = new Date();
|
|
@@ -38,8 +155,35 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
38
155
|
|
|
39
156
|
const cwd = workingDir || process.env.CLAUDE_WORKING_DIR || process.cwd();
|
|
40
157
|
|
|
158
|
+
// Resolve binary path and get version
|
|
159
|
+
const binaryPath = await getClaudeBinaryPath();
|
|
160
|
+
await getClaudeVersion(binaryPath);
|
|
161
|
+
|
|
41
162
|
// Build CLI arguments
|
|
42
|
-
const args =
|
|
163
|
+
const args = this.buildArgs(binaryPath, options);
|
|
164
|
+
|
|
165
|
+
console.log('[claude] Spawning with args:', args);
|
|
166
|
+
console.log('[claude] Working directory:', cwd);
|
|
167
|
+
|
|
168
|
+
// Spawn the process
|
|
169
|
+
const result = await this.spawnProcess(args, cwd, sessionId, resume);
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build CLI arguments based on options.
|
|
176
|
+
*/
|
|
177
|
+
private buildArgs(binaryPath: string, options: SpawnOptions): string[] {
|
|
178
|
+
const { prompt, sessionId, resume, systemPrompt } = options;
|
|
179
|
+
|
|
180
|
+
const args: string[] = [];
|
|
181
|
+
|
|
182
|
+
if (binaryPath === 'npx') {
|
|
183
|
+
args.push('npx', '@anthropic-ai/claude-code');
|
|
184
|
+
} else {
|
|
185
|
+
args.push(binaryPath);
|
|
186
|
+
}
|
|
43
187
|
|
|
44
188
|
// Non-interactive mode
|
|
45
189
|
args.push('--print');
|
|
@@ -67,8 +211,19 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
67
211
|
// The actual prompt
|
|
68
212
|
args.push('-p', prompt);
|
|
69
213
|
|
|
70
|
-
|
|
71
|
-
|
|
214
|
+
return args;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Spawn the Claude process and handle fallback for resume failures.
|
|
219
|
+
*/
|
|
220
|
+
private async spawnProcess(
|
|
221
|
+
args: string[],
|
|
222
|
+
cwd: string,
|
|
223
|
+
sessionId: string,
|
|
224
|
+
resume: boolean
|
|
225
|
+
): Promise<SpawnResult> {
|
|
226
|
+
let proc = Bun.spawn(args, {
|
|
72
227
|
cwd,
|
|
73
228
|
env: {
|
|
74
229
|
...process.env,
|
|
@@ -79,11 +234,61 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
79
234
|
});
|
|
80
235
|
|
|
81
236
|
// Collect output
|
|
82
|
-
|
|
83
|
-
|
|
237
|
+
let stdout = await new Response(proc.stdout).text();
|
|
238
|
+
let stderr = await new Response(proc.stderr).text();
|
|
84
239
|
|
|
85
240
|
// Wait for process to exit
|
|
86
|
-
|
|
241
|
+
let exitCode = await proc.exited;
|
|
242
|
+
|
|
243
|
+
console.log('[claude] Exit code:', exitCode);
|
|
244
|
+
if (stderr) {
|
|
245
|
+
console.log('[claude] Stderr:', stderr);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Handle --resume fallback
|
|
249
|
+
if (
|
|
250
|
+
resume &&
|
|
251
|
+
exitCode !== 0 &&
|
|
252
|
+
(stderr.includes('No conversation found') || stderr.includes('Session not found'))
|
|
253
|
+
) {
|
|
254
|
+
console.log(
|
|
255
|
+
`[claude] --resume failed for ${sessionId}, falling back to --continue`
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Rebuild args with --continue instead of --resume
|
|
259
|
+
const continueArgs = args.map((arg, i) => {
|
|
260
|
+
if (arg === '--resume') {
|
|
261
|
+
return '--continue';
|
|
262
|
+
}
|
|
263
|
+
// Skip the session ID that follows --resume
|
|
264
|
+
if (i > 0 && args[i - 1] === '--resume') {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return arg;
|
|
268
|
+
}).filter((arg): arg is string => arg !== null);
|
|
269
|
+
|
|
270
|
+
console.log('[claude] Retrying with args:', continueArgs);
|
|
271
|
+
|
|
272
|
+
// Retry with --continue
|
|
273
|
+
proc = Bun.spawn(continueArgs, {
|
|
274
|
+
cwd,
|
|
275
|
+
env: {
|
|
276
|
+
...process.env,
|
|
277
|
+
TZ: TIMEZONE,
|
|
278
|
+
},
|
|
279
|
+
stdout: 'pipe',
|
|
280
|
+
stderr: 'pipe',
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
stdout = await new Response(proc.stdout).text();
|
|
284
|
+
stderr = await new Response(proc.stderr).text();
|
|
285
|
+
exitCode = await proc.exited;
|
|
286
|
+
|
|
287
|
+
console.log('[claude] Fallback exit code:', exitCode);
|
|
288
|
+
if (stderr) {
|
|
289
|
+
console.log('[claude] Fallback stderr:', stderr);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
87
292
|
|
|
88
293
|
if (exitCode !== 0) {
|
|
89
294
|
throw new Error(`Claude CLI failed (exit ${exitCode}): ${stderr || 'Unknown error'}`);
|
package/src/adapters/codex.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
|
|
2
|
+
import { getHomeCandidate, getSystemBinaryCandidates, resolveBinary, resolveNpmGlobalBinary } from './resolve-binary.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Codex CLI Adapter
|
|
@@ -11,13 +12,58 @@ import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
|
|
|
11
12
|
* - `--json`: Structured output
|
|
12
13
|
*/
|
|
13
14
|
|
|
15
|
+
// Cache resolved binary path
|
|
16
|
+
let cachedBinaryPath: string | null = null;
|
|
17
|
+
|
|
14
18
|
export class CodexAdapter implements AgentAdapter {
|
|
15
19
|
readonly name = 'codex';
|
|
16
20
|
|
|
21
|
+
private async getBinaryPath(): Promise<string> {
|
|
22
|
+
const envValue = process.env.CODEX_BIN;
|
|
23
|
+
if (envValue) {
|
|
24
|
+
if (cachedBinaryPath !== envValue) {
|
|
25
|
+
cachedBinaryPath = envValue;
|
|
26
|
+
console.log(`[codex] Binary resolved (env): ${envValue}`);
|
|
27
|
+
}
|
|
28
|
+
return cachedBinaryPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (cachedBinaryPath) {
|
|
32
|
+
return cachedBinaryPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const resolved = await resolveBinary({
|
|
36
|
+
name: 'codex',
|
|
37
|
+
candidates: [
|
|
38
|
+
...getSystemBinaryCandidates('codex'),
|
|
39
|
+
getHomeCandidate('.codex', 'bin', 'codex'),
|
|
40
|
+
],
|
|
41
|
+
windowsCandidates: [getHomeCandidate('.codex', 'bin', 'codex.exe')],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (resolved) {
|
|
45
|
+
cachedBinaryPath = resolved.path;
|
|
46
|
+
console.log(`[codex] Binary resolved (${resolved.source}): ${resolved.path}`);
|
|
47
|
+
return cachedBinaryPath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const npmBinary = await resolveNpmGlobalBinary('codex');
|
|
51
|
+
if (npmBinary) {
|
|
52
|
+
cachedBinaryPath = npmBinary;
|
|
53
|
+
console.log(`[codex] Binary resolved (npm): ${npmBinary}`);
|
|
54
|
+
return cachedBinaryPath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new Error(
|
|
58
|
+
'Codex CLI not found. Install it or set CODEX_BIN to the binary path.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
17
62
|
async spawn(options: SpawnOptions): Promise<SpawnResult> {
|
|
18
63
|
const { prompt, sessionId, resume, workingDir } = options;
|
|
19
64
|
|
|
20
|
-
const
|
|
65
|
+
const binaryPath = await this.getBinaryPath();
|
|
66
|
+
const args = [binaryPath, 'exec'];
|
|
21
67
|
|
|
22
68
|
// Session handling
|
|
23
69
|
if (resume) {
|
package/src/adapters/opencode.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
|
|
2
|
+
import { getHomeCandidate, getSystemBinaryCandidates, resolveBinary, resolveNpmGlobalBinary } from './resolve-binary.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* OpenCode CLI Adapter
|
|
@@ -12,13 +13,58 @@ import type { AgentAdapter, SpawnOptions, SpawnResult } from './types.js';
|
|
|
12
13
|
* - `--cwd <path>`: Set working directory
|
|
13
14
|
*/
|
|
14
15
|
|
|
16
|
+
// Cache resolved binary path
|
|
17
|
+
let cachedBinaryPath: string | null = null;
|
|
18
|
+
|
|
15
19
|
export class OpenCodeAdapter implements AgentAdapter {
|
|
16
20
|
readonly name = 'opencode';
|
|
17
21
|
|
|
22
|
+
private async getBinaryPath(): Promise<string> {
|
|
23
|
+
const envValue = process.env.OPENCODE_BIN;
|
|
24
|
+
if (envValue) {
|
|
25
|
+
if (cachedBinaryPath !== envValue) {
|
|
26
|
+
cachedBinaryPath = envValue;
|
|
27
|
+
console.log(`[opencode] Binary resolved (env): ${envValue}`);
|
|
28
|
+
}
|
|
29
|
+
return cachedBinaryPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (cachedBinaryPath) {
|
|
33
|
+
return cachedBinaryPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const resolved = await resolveBinary({
|
|
37
|
+
name: 'opencode',
|
|
38
|
+
candidates: [
|
|
39
|
+
...getSystemBinaryCandidates('opencode'),
|
|
40
|
+
getHomeCandidate('.opencode', 'bin', 'opencode'),
|
|
41
|
+
],
|
|
42
|
+
windowsCandidates: [getHomeCandidate('.opencode', 'bin', 'opencode.exe')],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (resolved) {
|
|
46
|
+
cachedBinaryPath = resolved.path;
|
|
47
|
+
console.log(`[opencode] Binary resolved (${resolved.source}): ${resolved.path}`);
|
|
48
|
+
return cachedBinaryPath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const npmBinary = await resolveNpmGlobalBinary('opencode');
|
|
52
|
+
if (npmBinary) {
|
|
53
|
+
cachedBinaryPath = npmBinary;
|
|
54
|
+
console.log(`[opencode] Binary resolved (npm): ${npmBinary}`);
|
|
55
|
+
return cachedBinaryPath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(
|
|
59
|
+
'OpenCode CLI not found. Install it or set OPENCODE_BIN to the binary path.'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
18
63
|
async spawn(options: SpawnOptions): Promise<SpawnResult> {
|
|
19
64
|
const { prompt, sessionId, resume, workingDir } = options;
|
|
20
65
|
|
|
21
|
-
const
|
|
66
|
+
const binaryPath = await this.getBinaryPath();
|
|
67
|
+
const args = [binaryPath, 'run'];
|
|
22
68
|
|
|
23
69
|
// Format as JSON for structured output
|
|
24
70
|
args.push('--format', 'json');
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
export type BinarySource = 'env' | 'path' | 'candidate' | 'npm';
|
|
6
|
+
|
|
7
|
+
export interface ResolveBinaryOptions {
|
|
8
|
+
name: string;
|
|
9
|
+
candidates?: string[];
|
|
10
|
+
windowsCandidates?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ResolveBinaryResult {
|
|
14
|
+
path: string;
|
|
15
|
+
source: BinarySource;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function resolveBinary(
|
|
19
|
+
options: ResolveBinaryOptions
|
|
20
|
+
): Promise<ResolveBinaryResult | null> {
|
|
21
|
+
const isWindows = process.platform === 'win32';
|
|
22
|
+
const whichCommand = isWindows ? 'where.exe' : 'which';
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const proc = Bun.spawn([whichCommand, options.name], {
|
|
26
|
+
stdout: 'pipe',
|
|
27
|
+
stderr: 'pipe',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const stdout = await new Response(proc.stdout).text();
|
|
31
|
+
const exitCode = await proc.exited;
|
|
32
|
+
|
|
33
|
+
if (exitCode === 0 && stdout.trim()) {
|
|
34
|
+
const firstPath = stdout.trim().split('\n')[0];
|
|
35
|
+
if (firstPath) {
|
|
36
|
+
return { path: firstPath, source: 'path' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore and fall through to candidates
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const candidates = isWindows ? options.windowsCandidates : options.candidates;
|
|
44
|
+
if (candidates) {
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
if (candidate && existsSync(candidate)) {
|
|
47
|
+
return { path: candidate, source: 'candidate' };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getSystemBinaryCandidates(name: string): string[] {
|
|
56
|
+
return [
|
|
57
|
+
`/opt/homebrew/bin/${name}`,
|
|
58
|
+
`/usr/local/bin/${name}`,
|
|
59
|
+
`/usr/bin/${name}`,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function resolveNpmGlobalBinary(binaryName: string): Promise<string | null> {
|
|
64
|
+
try {
|
|
65
|
+
const proc = Bun.spawn(['npm', 'bin', '-g'], {
|
|
66
|
+
stdout: 'pipe',
|
|
67
|
+
stderr: 'pipe',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const stdout = await new Response(proc.stdout).text();
|
|
71
|
+
const exitCode = await proc.exited;
|
|
72
|
+
|
|
73
|
+
if (exitCode !== 0) return null;
|
|
74
|
+
|
|
75
|
+
const binDir = stdout.trim().split('\n')[0];
|
|
76
|
+
if (!binDir) return null;
|
|
77
|
+
|
|
78
|
+
const candidates = [join(binDir, binaryName)];
|
|
79
|
+
if (process.platform === 'win32') {
|
|
80
|
+
candidates.unshift(join(binDir, `${binaryName}.cmd`));
|
|
81
|
+
candidates.unshift(join(binDir, `${binaryName}.exe`));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const candidate of candidates) {
|
|
85
|
+
if (existsSync(candidate)) return candidate;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getHomeCandidate(...parts: string[]): string {
|
|
95
|
+
return join(homedir(), ...parts);
|
|
96
|
+
}
|
package/src/bot.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { generateThreadName } from './features/thread-naming.js';
|
|
|
36
36
|
import { checkSessionLimits } from './features/session-limits.js';
|
|
37
37
|
import { handlePauseResume } from './features/pause-resume.js';
|
|
38
38
|
import { isBrbMessage, isBackMessage, setBrb, setBack } from './features/brb.js';
|
|
39
|
+
import { listSessions, formatAge } from './features/sessions.js';
|
|
39
40
|
import { questionResponses, pendingTypedAnswers } from './api.js';
|
|
40
41
|
|
|
41
42
|
// DM support - opt-in via env var (disabled by default for security)
|
|
@@ -146,24 +147,43 @@ client.once(Events.ClientReady, async (c) => {
|
|
|
146
147
|
const existingCommands = await c.application?.commands.fetch();
|
|
147
148
|
const cordCommand = existingCommands?.find(cmd => cmd.name === 'cord');
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
150
|
+
// Always re-register to pick up new subcommands
|
|
151
|
+
const command = new SlashCommandBuilder()
|
|
152
|
+
.setName('cord')
|
|
153
|
+
.setDescription('Configure Cord bot')
|
|
154
|
+
.addSubcommand(sub =>
|
|
155
|
+
sub.setName('config')
|
|
156
|
+
.setDescription('Configure channel settings')
|
|
157
|
+
.addStringOption(opt =>
|
|
158
|
+
opt.setName('dir')
|
|
159
|
+
.setDescription('Working directory for Claude in this channel')
|
|
160
|
+
.setRequired(true)
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
.addSubcommand(sub =>
|
|
164
|
+
sub.setName('sessions')
|
|
165
|
+
.setDescription('List resumable Claude Code sessions')
|
|
166
|
+
.addStringOption(opt =>
|
|
167
|
+
opt.setName('dir')
|
|
168
|
+
.setDescription('Project directory to list sessions for (defaults to channel config)')
|
|
169
|
+
.setRequired(false)
|
|
170
|
+
)
|
|
171
|
+
.addIntegerOption(opt =>
|
|
172
|
+
opt.setName('limit')
|
|
173
|
+
.setDescription('Max sessions to show (default 5)')
|
|
174
|
+
.setRequired(false)
|
|
175
|
+
.setMinValue(1)
|
|
176
|
+
.setMaxValue(25)
|
|
177
|
+
)
|
|
178
|
+
);
|
|
162
179
|
|
|
180
|
+
if (!cordCommand) {
|
|
163
181
|
await c.application?.commands.create(command);
|
|
164
182
|
log('Slash commands registered');
|
|
165
183
|
} else {
|
|
166
|
-
|
|
184
|
+
// Update existing command to include new subcommands
|
|
185
|
+
await cordCommand.edit(command);
|
|
186
|
+
log('Slash commands updated');
|
|
167
187
|
}
|
|
168
188
|
|
|
169
189
|
// Start HTTP API server
|
|
@@ -204,6 +224,48 @@ client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
|
|
204
224
|
});
|
|
205
225
|
log(`Channel ${interaction.channelId} configured with working dir: ${dir}`);
|
|
206
226
|
}
|
|
227
|
+
|
|
228
|
+
if (subcommand === 'sessions') {
|
|
229
|
+
// Resolve project directory: explicit option > channel config > env > cwd
|
|
230
|
+
let projectDir = interaction.options.getString('dir');
|
|
231
|
+
if (projectDir) {
|
|
232
|
+
if (projectDir.startsWith('~')) {
|
|
233
|
+
projectDir = projectDir.replace('~', homedir());
|
|
234
|
+
}
|
|
235
|
+
projectDir = resolve(projectDir);
|
|
236
|
+
} else {
|
|
237
|
+
const channelConfig = getChannelConfigCached(interaction.channelId);
|
|
238
|
+
projectDir = channelConfig?.working_dir
|
|
239
|
+
|| process.env.CLAUDE_WORKING_DIR
|
|
240
|
+
|| process.cwd();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const limit = interaction.options.getInteger('limit') ?? 5;
|
|
244
|
+
const sessions = listSessions(projectDir, limit);
|
|
245
|
+
|
|
246
|
+
if (sessions.length === 0) {
|
|
247
|
+
await interaction.reply({
|
|
248
|
+
content: `No Claude sessions found for \`${projectDir}\`.`,
|
|
249
|
+
ephemeral: true,
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Format session list
|
|
255
|
+
const lines = sessions.map((s, i) => {
|
|
256
|
+
const age = formatAge(s.lastActivity);
|
|
257
|
+
const preview = s.firstMessage
|
|
258
|
+
? s.firstMessage.slice(0, 80) + (s.firstMessage.length > 80 ? '…' : '')
|
|
259
|
+
: '(no messages)';
|
|
260
|
+
return `**${i + 1}.** \`${s.id.slice(0, 8)}…\` — ${age} ago, ${s.messageCount} msgs\n> ${preview}`;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await interaction.reply({
|
|
264
|
+
content: `**Claude Sessions** for \`${projectDir}\`\n\n${lines.join('\n\n')}`,
|
|
265
|
+
ephemeral: true,
|
|
266
|
+
});
|
|
267
|
+
log(`Listed ${sessions.length} sessions for ${projectDir}`);
|
|
268
|
+
}
|
|
207
269
|
return;
|
|
208
270
|
}
|
|
209
271
|
|
package/src/config.ts
CHANGED
|
@@ -58,6 +58,9 @@ const CONFIG_KEYS: Record<string, ConfigKeyMeta> = {
|
|
|
58
58
|
// Agent
|
|
59
59
|
AGENT_TYPE: { section: 'agent', default: 'claude', description: 'Agent type (claude, opencode, codex)' },
|
|
60
60
|
CLAUDE_WORKING_DIR: { section: 'agent', default: '', description: 'Default working directory for agent sessions' },
|
|
61
|
+
CLAUDE_BIN: { section: 'agent', default: '', description: 'Override path to Claude CLI binary' },
|
|
62
|
+
OPENCODE_BIN: { section: 'agent', default: '', description: 'Override path to OpenCode CLI binary' },
|
|
63
|
+
CODEX_BIN: { section: 'agent', default: '', description: 'Override path to Codex CLI binary' },
|
|
61
64
|
|
|
62
65
|
// Server
|
|
63
66
|
TETHER_API_HOST: { section: 'server', default: '127.0.0.1', description: 'API server bind address' },
|
|
@@ -226,7 +229,11 @@ export function writeSecret(key: string, value: string, password: string): void
|
|
|
226
229
|
|
|
227
230
|
let secrets: Record<string, string> = {};
|
|
228
231
|
if (existsSync(getSecretsPath())) {
|
|
229
|
-
|
|
232
|
+
try {
|
|
233
|
+
secrets = readSecrets(password);
|
|
234
|
+
} catch {
|
|
235
|
+
throw new Error('Wrong password. Use the same password you set previously, or delete ~/.config/tether/secrets.enc to start fresh.');
|
|
236
|
+
}
|
|
230
237
|
}
|
|
231
238
|
|
|
232
239
|
secrets[key] = value;
|
|
@@ -240,7 +247,12 @@ export function deleteKey(key: string, password?: string): boolean {
|
|
|
240
247
|
if (!password) throw new Error('Password required to modify secrets');
|
|
241
248
|
if (!existsSync(getSecretsPath())) return false;
|
|
242
249
|
|
|
243
|
-
|
|
250
|
+
let secrets: Record<string, string>;
|
|
251
|
+
try {
|
|
252
|
+
secrets = readSecrets(password);
|
|
253
|
+
} catch {
|
|
254
|
+
throw new Error('Wrong password. Use the same password you set previously.');
|
|
255
|
+
}
|
|
244
256
|
if (!(key in secrets)) return false;
|
|
245
257
|
delete secrets[key];
|
|
246
258
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
export interface SessionInfo {
|
|
6
|
+
id: string;
|
|
7
|
+
createdAt: Date;
|
|
8
|
+
lastActivity: Date;
|
|
9
|
+
messageCount: number;
|
|
10
|
+
firstMessage: string;
|
|
11
|
+
cwd: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SessionLine {
|
|
15
|
+
type?: string;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
uuid?: string;
|
|
18
|
+
parentUuid?: string | null;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
timestamp?: string;
|
|
21
|
+
message?: {
|
|
22
|
+
role?: string;
|
|
23
|
+
content?: string | Array<{ type: string; text?: string }>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert a filesystem path to Claude's sanitized directory name.
|
|
29
|
+
* Claude replaces `/`, `\`, and `:` with `-`.
|
|
30
|
+
*
|
|
31
|
+
* Examples:
|
|
32
|
+
* - macOS: `/Users/sam/project` → `-Users-sam-project`
|
|
33
|
+
* - Windows: `C:\Github\project` → `C--Github-project`
|
|
34
|
+
* - Linux: `/home/user/project` → `-home-user-project`
|
|
35
|
+
*/
|
|
36
|
+
export function sanitizePath(projectPath: string): string {
|
|
37
|
+
return projectPath.replace(/[/\\:]/g, '-');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return the full path to Claude's sessions directory for a given project path.
|
|
42
|
+
*/
|
|
43
|
+
export function getSessionsDir(projectPath: string): string {
|
|
44
|
+
const sanitized = sanitizePath(projectPath);
|
|
45
|
+
return join(homedir(), '.claude', 'projects', sanitized);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a JSONL session file and extract metadata.
|
|
50
|
+
* Returns null if the file doesn't exist, is empty, or cannot be parsed.
|
|
51
|
+
*/
|
|
52
|
+
export function parseSessionFile(filePath: string): SessionInfo | null {
|
|
53
|
+
if (!existsSync(filePath)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const content = readFileSync(filePath, 'utf-8').trim();
|
|
59
|
+
if (!content) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lines = content.split('\n');
|
|
64
|
+
const messages: SessionLine[] = [];
|
|
65
|
+
|
|
66
|
+
// Parse each line, skipping corrupted ones
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (!line.trim()) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(line) as SessionLine;
|
|
74
|
+
messages.push(parsed);
|
|
75
|
+
} catch {
|
|
76
|
+
// Skip corrupted lines
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (messages.length === 0) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Extract metadata
|
|
86
|
+
const firstMsg = messages[0];
|
|
87
|
+
if (!firstMsg) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const lastMsg = messages[messages.length - 1];
|
|
91
|
+
if (!lastMsg) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Get session ID
|
|
96
|
+
const sessionId = firstMsg.sessionId || firstMsg.uuid || 'unknown';
|
|
97
|
+
|
|
98
|
+
// Get CWD (usually from first message)
|
|
99
|
+
const cwd = firstMsg.cwd || '';
|
|
100
|
+
|
|
101
|
+
// Get timestamps
|
|
102
|
+
const createdAt = firstMsg.timestamp ? new Date(firstMsg.timestamp) : new Date(0);
|
|
103
|
+
const lastActivity = lastMsg.timestamp ? new Date(lastMsg.timestamp) : createdAt;
|
|
104
|
+
|
|
105
|
+
// Get first user message content
|
|
106
|
+
let firstMessage = '';
|
|
107
|
+
for (const msg of messages) {
|
|
108
|
+
if (msg.type === 'user' && msg.message?.content) {
|
|
109
|
+
const content = msg.message.content;
|
|
110
|
+
if (typeof content === 'string') {
|
|
111
|
+
firstMessage = content;
|
|
112
|
+
} else if (Array.isArray(content)) {
|
|
113
|
+
// Handle array of content blocks
|
|
114
|
+
const textBlock = content.find(block => block.type === 'text' && block.text);
|
|
115
|
+
if (textBlock && textBlock.text) {
|
|
116
|
+
firstMessage = textBlock.text;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: sessionId,
|
|
125
|
+
createdAt,
|
|
126
|
+
lastActivity,
|
|
127
|
+
messageCount: messages.length,
|
|
128
|
+
firstMessage,
|
|
129
|
+
cwd,
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* List all sessions for a project path, sorted by lastActivity descending.
|
|
138
|
+
* Returns empty array if the sessions directory doesn't exist.
|
|
139
|
+
*/
|
|
140
|
+
export function listSessions(projectPath: string, limit?: number): SessionInfo[] {
|
|
141
|
+
const sessionsDir = getSessionsDir(projectPath);
|
|
142
|
+
|
|
143
|
+
if (!existsSync(sessionsDir)) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const files = readdirSync(sessionsDir);
|
|
149
|
+
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
150
|
+
|
|
151
|
+
const sessions: SessionInfo[] = [];
|
|
152
|
+
|
|
153
|
+
for (const file of jsonlFiles) {
|
|
154
|
+
const filePath = join(sessionsDir, file);
|
|
155
|
+
|
|
156
|
+
// Skip if not a file
|
|
157
|
+
try {
|
|
158
|
+
const stats = statSync(filePath);
|
|
159
|
+
if (!stats.isFile()) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const session = parseSessionFile(filePath);
|
|
167
|
+
if (session) {
|
|
168
|
+
sessions.push(session);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Sort by lastActivity descending (newest first)
|
|
173
|
+
sessions.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
|
|
174
|
+
|
|
175
|
+
// Apply limit if specified
|
|
176
|
+
if (limit !== undefined && limit > 0) {
|
|
177
|
+
return sessions.slice(0, limit);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return sessions;
|
|
181
|
+
} catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Format a date as a human-readable relative time string.
|
|
188
|
+
* e.g. "2m", "3h", "1d", "2w"
|
|
189
|
+
*/
|
|
190
|
+
export function formatAge(date: Date): string {
|
|
191
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
192
|
+
if (seconds < 60) return `${seconds}s`;
|
|
193
|
+
const minutes = Math.floor(seconds / 60);
|
|
194
|
+
if (minutes < 60) return `${minutes}m`;
|
|
195
|
+
const hours = Math.floor(minutes / 60);
|
|
196
|
+
if (hours < 24) return `${hours}h`;
|
|
197
|
+
const days = Math.floor(hours / 24);
|
|
198
|
+
if (days < 7) return `${days}d`;
|
|
199
|
+
const weeks = Math.floor(days / 7);
|
|
200
|
+
return `${weeks}w`;
|
|
201
|
+
}
|