kimaki 0.4.96 → 0.4.98

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.
Files changed (40) hide show
  1. package/dist/anthropic-account-identity.js +62 -0
  2. package/dist/anthropic-account-identity.test.js +38 -0
  3. package/dist/anthropic-auth-plugin.js +72 -12
  4. package/dist/anthropic-auth-state.js +28 -3
  5. package/dist/anthropic-auth-state.test.js +150 -0
  6. package/dist/cli-parsing.test.js +12 -9
  7. package/dist/cli.js +25 -12
  8. package/dist/commands/screenshare.js +1 -1
  9. package/dist/commands/screenshare.test.js +2 -2
  10. package/dist/commands/vscode.js +284 -0
  11. package/dist/commands/vscode.test.js +44 -0
  12. package/dist/discord-command-registration.js +7 -2
  13. package/dist/gateway-proxy-reconnect.e2e.test.js +2 -2
  14. package/dist/interaction-handler.js +4 -0
  15. package/dist/kimaki-opencode-plugin-specs.js +13 -0
  16. package/dist/system-message.js +24 -23
  17. package/dist/system-message.test.js +24 -23
  18. package/dist/system-prompt-drift-plugin.js +41 -11
  19. package/dist/utils.js +1 -1
  20. package/dist/worktrees.js +0 -33
  21. package/package.json +5 -5
  22. package/src/anthropic-account-identity.test.ts +52 -0
  23. package/src/anthropic-account-identity.ts +77 -0
  24. package/src/anthropic-auth-plugin.ts +81 -12
  25. package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
  26. package/src/anthropic-auth-state.ts +36 -3
  27. package/src/cli-parsing.test.ts +16 -9
  28. package/src/cli.ts +31 -13
  29. package/src/commands/screenshare.test.ts +2 -2
  30. package/src/commands/screenshare.ts +1 -1
  31. package/src/commands/vscode.ts +367 -0
  32. package/src/discord-command-registration.ts +9 -2
  33. package/src/gateway-proxy-reconnect.e2e.test.ts +2 -2
  34. package/src/interaction-handler.ts +5 -0
  35. package/src/system-message.test.ts +24 -23
  36. package/src/system-message.ts +24 -23
  37. package/src/system-prompt-drift-plugin.ts +48 -12
  38. package/src/utils.ts +1 -1
  39. package/src/worktrees.test.ts +1 -0
  40. package/src/worktrees.ts +1 -47
@@ -1,10 +1,11 @@
1
- // Tests for Anthropic OAuth multi-account persistence and rotation.
1
+ // Tests Anthropic OAuth account persistence, deduplication, and rotation.
2
2
 
3
3
  import { mkdtemp, readFile, rm, mkdir, writeFile } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import path from 'node:path'
6
6
  import { afterEach, beforeEach, describe, expect, test } from 'vitest'
7
7
  import {
8
+ accountLabel,
8
9
  authFilePath,
9
10
  loadAccountStore,
10
11
  rememberAnthropicOAuth,
@@ -60,6 +61,27 @@ describe('rememberAnthropicOAuth', () => {
60
61
  expires: 3,
61
62
  })
62
63
  })
64
+
65
+ test('deduplicates new tokens by email or account ID', async () => {
66
+ await rememberAnthropicOAuth(firstAccount, {
67
+ email: 'user@example.com',
68
+ accountId: 'usr_123',
69
+ })
70
+ await rememberAnthropicOAuth(secondAccount, {
71
+ email: 'User@example.com',
72
+ accountId: 'usr_123',
73
+ })
74
+
75
+ const store = await loadAccountStore()
76
+ expect(store.accounts).toHaveLength(1)
77
+ expect(store.accounts[0]).toMatchObject({
78
+ refresh: 'refresh-second',
79
+ access: 'access-second',
80
+ email: 'user@example.com',
81
+ accountId: 'usr_123',
82
+ })
83
+ expect(accountLabel(store.accounts[0]!)).toBe('user@example.com')
84
+ })
63
85
  })
64
86
 
65
87
  describe('rotateAnthropicAccount', () => {
@@ -2,6 +2,10 @@ import type { Plugin } from '@opencode-ai/plugin'
2
2
  import * as fs from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import path from 'node:path'
5
+ import {
6
+ normalizeAnthropicAccountIdentity,
7
+ type AnthropicAccountIdentity,
8
+ } from './anthropic-account-identity.js'
5
9
 
6
10
  const AUTH_LOCK_STALE_MS = 30_000
7
11
  const AUTH_LOCK_RETRY_MS = 100
@@ -14,6 +18,8 @@ export type OAuthStored = {
14
18
  }
15
19
 
16
20
  type AccountRecord = OAuthStored & {
21
+ email?: string
22
+ accountId?: string
17
23
  addedAt: number
18
24
  lastUsed: number
19
25
  }
@@ -114,6 +120,8 @@ export function normalizeAccountStore(
114
120
  typeof account.refresh === 'string' &&
115
121
  typeof account.access === 'string' &&
116
122
  typeof account.expires === 'number' &&
123
+ (typeof account.email === 'undefined' || typeof account.email === 'string') &&
124
+ (typeof account.accountId === 'undefined' || typeof account.accountId === 'string') &&
117
125
  typeof account.addedAt === 'number' &&
118
126
  typeof account.lastUsed === 'number',
119
127
  )
@@ -135,8 +143,13 @@ export async function saveAccountStore(store: AccountStore) {
135
143
 
136
144
  /** Short label for an account: first 8 + last 4 chars of refresh token. */
137
145
  export function accountLabel(account: OAuthStored, index?: number): string {
146
+ const accountWithIdentity = account as OAuthStored & AnthropicAccountIdentity
147
+ const identity = accountWithIdentity.email || accountWithIdentity.accountId
138
148
  const r = account.refresh
139
149
  const short = r.length > 12 ? `${r.slice(0, 8)}...${r.slice(-4)}` : r
150
+ if (identity) {
151
+ return index !== undefined ? `#${index + 1} (${identity})` : identity
152
+ }
140
153
  return index !== undefined ? `#${index + 1} (${short})` : short
141
154
  }
142
155
 
@@ -162,14 +175,29 @@ function findCurrentAccountIndex(store: AccountStore, auth: OAuthStored) {
162
175
  }
163
176
 
164
177
  export function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date.now()) {
178
+ const authWithIdentity = auth as OAuthStored & AnthropicAccountIdentity
179
+ const identity = normalizeAnthropicAccountIdentity({
180
+ email: authWithIdentity.email,
181
+ accountId: authWithIdentity.accountId,
182
+ })
165
183
  const index = store.accounts.findIndex((account) => {
166
- return account.refresh === auth.refresh || account.access === auth.access
184
+ if (account.refresh === auth.refresh || account.access === auth.access) {
185
+ return true
186
+ }
187
+ if (identity?.accountId && account.accountId === identity.accountId) {
188
+ return true
189
+ }
190
+ if (identity?.email && account.email === identity.email) {
191
+ return true
192
+ }
193
+ return false
167
194
  })
168
195
  const nextAccount: AccountRecord = {
169
196
  type: 'oauth',
170
197
  refresh: auth.refresh,
171
198
  access: auth.access,
172
199
  expires: auth.expires,
200
+ ...identity,
173
201
  addedAt: now,
174
202
  lastUsed: now,
175
203
  }
@@ -186,15 +214,20 @@ export function upsertAccount(store: AccountStore, auth: OAuthStored, now = Date
186
214
  ...existing,
187
215
  ...nextAccount,
188
216
  addedAt: existing.addedAt,
217
+ email: nextAccount.email || existing.email,
218
+ accountId: nextAccount.accountId || existing.accountId,
189
219
  }
190
220
  store.activeIndex = index
191
221
  return index
192
222
  }
193
223
 
194
- export async function rememberAnthropicOAuth(auth: OAuthStored) {
224
+ export async function rememberAnthropicOAuth(
225
+ auth: OAuthStored,
226
+ identity?: AnthropicAccountIdentity,
227
+ ) {
195
228
  await withAuthStateLock(async () => {
196
229
  const store = await loadAccountStore()
197
- upsertAccount(store, auth)
230
+ upsertAccount(store, { ...auth, ...normalizeAnthropicAccountIdentity(identity) })
198
231
  await saveAccountStore(store)
199
232
  })
200
233
  }
@@ -27,8 +27,8 @@ function createCliForIdParsing() {
27
27
  .option('-g, --guild <guildId>', 'Discord guild/server ID')
28
28
 
29
29
  cli.command('task delete <id>', 'Delete task')
30
- cli.command('anthropic-accounts list', 'List stored Anthropic accounts').hidden()
31
- cli.command('anthropic-accounts remove <index>', 'Remove stored Anthropic account').hidden()
30
+ cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
31
+ cli.command('anthropic-accounts remove <indexOrEmail>', 'Remove stored Anthropic account')
32
32
 
33
33
  return cli
34
34
  }
@@ -163,19 +163,26 @@ describe('goke CLI ID parsing', () => {
163
163
  expect(typeof result.args[0]).toBe('string')
164
164
  })
165
165
 
166
- test('hidden anthropic account commands still parse', () => {
166
+ test('anthropic account remove parses index and email as strings', () => {
167
167
  const cli = createCliForIdParsing()
168
168
 
169
- const result = cli.parse(
169
+ const indexResult = cli.parse(
170
170
  ['node', 'kimaki', 'anthropic-accounts', 'remove', '2'],
171
171
  { run: false },
172
172
  )
173
173
 
174
- expect(result.args[0]).toBe('2')
175
- expect(typeof result.args[0]).toBe('string')
174
+ const emailResult = cli.parse(
175
+ ['node', 'kimaki', 'anthropic-accounts', 'remove', 'user@example.com'],
176
+ { run: false },
177
+ )
178
+
179
+ expect(indexResult.args[0]).toBe('2')
180
+ expect(typeof indexResult.args[0]).toBe('string')
181
+ expect(emailResult.args[0]).toBe('user@example.com')
182
+ expect(typeof emailResult.args[0]).toBe('string')
176
183
  })
177
184
 
178
- test('hidden anthropic account commands are excluded from help output', () => {
185
+ test('anthropic account commands are included in help output', () => {
179
186
  const stdout = {
180
187
  text: '',
181
188
  write(data: string | Uint8Array) {
@@ -185,11 +192,11 @@ describe('goke CLI ID parsing', () => {
185
192
 
186
193
  const cli = goke('kimaki', { stdout: stdout as never })
187
194
  cli.command('send', 'Send a message')
188
- cli.command('anthropic-accounts list', 'List stored Anthropic accounts').hidden()
195
+ cli.command('anthropic-accounts list', 'List stored Anthropic accounts')
189
196
  cli.help()
190
197
  cli.parse(['node', 'kimaki', '--help'], { run: false })
191
198
 
192
199
  expect(stdout.text).toContain('send')
193
- expect(stdout.text).not.toContain('anthropic-accounts')
200
+ expect(stdout.text).toContain('anthropic-accounts')
194
201
  })
195
202
  })
package/src/cli.ts CHANGED
@@ -141,7 +141,7 @@ const cliLogger = createLogger(LogPrefix.CLI)
141
141
  // These are hardcoded because they're deploy-time constants for the gateway infrastructure.
142
142
  const KIMAKI_GATEWAY_PROXY_URL =
143
143
  process.env.KIMAKI_GATEWAY_PROXY_URL ||
144
- 'wss://discord-gateway.kimaki.xyz'
144
+ 'wss://discord-gateway.kimaki.dev'
145
145
 
146
146
  const KIMAKI_GATEWAY_PROXY_REST_BASE_URL = getGatewayProxyRestBaseUrl({
147
147
  gatewayUrl: KIMAKI_GATEWAY_PROXY_URL,
@@ -1024,7 +1024,8 @@ async function resolveCredentials({
1024
1024
  options: [
1025
1025
  {
1026
1026
  value: 'gateway' as const,
1027
- label: 'Gateway (pre-built Kimaki bot — no setup needed)',
1027
+ disabled: true,
1028
+ label: 'Gateway (pre-built Kimaki bot, currently disabled because of Discord verification process. will be re-enabled soon)',
1028
1029
  },
1029
1030
  {
1030
1031
  value: 'self_hosted' as const,
@@ -3168,7 +3169,6 @@ cli
3168
3169
  'anthropic-accounts list',
3169
3170
  'List stored Anthropic OAuth accounts used for automatic rotation',
3170
3171
  )
3171
- .hidden()
3172
3172
  .action(async () => {
3173
3173
  const store = await loadAccountStore()
3174
3174
  console.log(`Store: ${accountsFilePath()}`)
@@ -3187,19 +3187,37 @@ cli
3187
3187
 
3188
3188
  cli
3189
3189
  .command(
3190
- 'anthropic-accounts remove <index>',
3191
- 'Remove a stored Anthropic OAuth account from the rotation pool',
3190
+ 'anthropic-accounts remove <indexOrEmail>',
3191
+ 'Remove a stored Anthropic OAuth account from the rotation pool by index or email',
3192
3192
  )
3193
- .hidden()
3194
- .action(async (index: string) => {
3195
- const value = Number(index)
3196
- if (!Number.isInteger(value) || value < 1) {
3197
- cliLogger.error('Usage: kimaki anthropic-accounts remove <index>')
3193
+ .action(async (indexOrEmail: string) => {
3194
+ const value = Number(indexOrEmail)
3195
+ const store = await loadAccountStore()
3196
+ const resolvedIndex = (() => {
3197
+ if (Number.isInteger(value) && value >= 1) {
3198
+ return value - 1
3199
+ }
3200
+ const email = indexOrEmail.trim().toLowerCase()
3201
+ if (!email) {
3202
+ return -1
3203
+ }
3204
+ return store.accounts.findIndex((account) => {
3205
+ return account.email?.toLowerCase() === email
3206
+ })
3207
+ })()
3208
+
3209
+ if (resolvedIndex < 0) {
3210
+ cliLogger.error(
3211
+ 'Usage: kimaki anthropic-accounts remove <index-or-email>',
3212
+ )
3198
3213
  process.exit(EXIT_NO_RESTART)
3199
3214
  }
3200
3215
 
3201
- await removeAccount(value - 1)
3202
- cliLogger.log(`Removed Anthropic account ${value}`)
3216
+ const removed = store.accounts[resolvedIndex]
3217
+ await removeAccount(resolvedIndex)
3218
+ cliLogger.log(
3219
+ `Removed Anthropic account ${removed ? accountLabel(removed, resolvedIndex) : indexOrEmail}`,
3220
+ )
3203
3221
  process.exit(0)
3204
3222
  })
3205
3223
 
@@ -3774,7 +3792,7 @@ cli
3774
3792
  port,
3775
3793
  tunnelId: options.tunnelId,
3776
3794
  localHost: options.host,
3777
- baseDomain: 'kimaki.xyz',
3795
+ baseDomain: 'kimaki.dev',
3778
3796
  serverUrl: options.server,
3779
3797
  command: command.length > 0 ? command : undefined,
3780
3798
  kill: options.kill,
@@ -17,12 +17,12 @@ describe('screenshare security defaults', () => {
17
17
 
18
18
  test('builds a secure noVNC URL', () => {
19
19
  const url = new URL(
20
- buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.xyz' }),
20
+ buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.dev' }),
21
21
  )
22
22
 
23
23
  expect(url.origin).toBe('https://novnc.com')
24
24
  expect(url.searchParams.get('host')).toBe(
25
- '0123456789abcdef-tunnel.kimaki.xyz',
25
+ '0123456789abcdef-tunnel.kimaki.dev',
26
26
  )
27
27
  expect(url.searchParams.get('port')).toBe('443')
28
28
  expect(url.searchParams.get('encrypt')).toBe('1')
@@ -40,7 +40,7 @@ const activeSessions = new Map<string, ScreenshareSession>()
40
40
  const VNC_PORT = 5900
41
41
  const MAX_SESSION_MINUTES = 30
42
42
  const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000
43
- const TUNNEL_BASE_DOMAIN = 'kimaki.xyz'
43
+ const TUNNEL_BASE_DOMAIN = 'kimaki.dev'
44
44
  const SCREENSHARE_TUNNEL_ID_BYTES = 16
45
45
 
46
46
  // Public noVNC client — we point it at our tunnel URL
@@ -0,0 +1,367 @@
1
+ import crypto from 'node:crypto'
2
+ import { spawn, type ChildProcess } from 'node:child_process'
3
+ import net from 'node:net'
4
+ import {
5
+ ChannelType,
6
+ MessageFlags,
7
+ type TextChannel,
8
+ type ThreadChannel,
9
+ } from 'discord.js'
10
+ import { TunnelClient } from 'traforo/client'
11
+ import type { CommandContext } from './types.js'
12
+ import {
13
+ resolveWorkingDirectory,
14
+ SILENT_MESSAGE_FLAGS,
15
+ } from '../discord-utils.js'
16
+ import { createLogger } from '../logger.js'
17
+
18
+ const logger = createLogger('VSCODE')
19
+ const SECURE_REPLY_FLAGS = MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS
20
+ const MAX_SESSION_MINUTES = 30
21
+ const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000
22
+ const TUNNEL_BASE_DOMAIN = 'kimaki.dev'
23
+ const TUNNEL_ID_BYTES = 16
24
+ const CONNECTION_TOKEN_BYTES = 16
25
+ const READY_TIMEOUT_MS = 60_000
26
+ const LOCAL_HOST = '127.0.0.1'
27
+
28
+ export type VscodeSession = {
29
+ coderaftProcess: ChildProcess
30
+ tunnelClient: TunnelClient
31
+ url: string
32
+ workingDirectory: string
33
+ startedBy: string
34
+ startedAt: number
35
+ timeoutTimer: ReturnType<typeof setTimeout>
36
+ }
37
+
38
+ const activeSessions = new Map<string, VscodeSession>()
39
+
40
+ export function createVscodeTunnelId(): string {
41
+ return crypto.randomBytes(TUNNEL_ID_BYTES).toString('hex')
42
+ }
43
+
44
+ export function createVscodeConnectionToken(): string {
45
+ return crypto.randomBytes(CONNECTION_TOKEN_BYTES).toString('hex')
46
+ }
47
+
48
+ export function buildVscodeUrl({
49
+ tunnelUrl,
50
+ connectionToken,
51
+ }: {
52
+ tunnelUrl: string
53
+ connectionToken: string
54
+ }): string {
55
+ const url = new URL(tunnelUrl)
56
+ url.searchParams.set('tkn', connectionToken)
57
+ return url.toString()
58
+ }
59
+
60
+ export function buildCoderaftArgs({
61
+ port,
62
+ connectionToken,
63
+ workingDirectory,
64
+ }: {
65
+ port: number
66
+ connectionToken: string
67
+ workingDirectory: string
68
+ }): string[] {
69
+ return [
70
+ 'coderaft',
71
+ '--port',
72
+ String(port),
73
+ '--host',
74
+ LOCAL_HOST,
75
+ '--connection-token',
76
+ connectionToken,
77
+ '--disable-workspace-trust',
78
+ '--default-folder',
79
+ workingDirectory,
80
+ ]
81
+ }
82
+
83
+ function createPortWaiter({
84
+ port,
85
+ process: proc,
86
+ timeoutMs,
87
+ }: {
88
+ port: number
89
+ process: ChildProcess
90
+ timeoutMs: number
91
+ }): Promise<void> {
92
+ return new Promise((resolve, reject) => {
93
+ const maxAttempts = Math.ceil(timeoutMs / 100)
94
+ let attempts = 0
95
+
96
+ const check = (): void => {
97
+ if (proc.exitCode !== null) {
98
+ reject(new Error(`coderaft exited with code ${proc.exitCode} before becoming ready`))
99
+ return
100
+ }
101
+
102
+ const socket = net.createConnection(port, LOCAL_HOST)
103
+ socket.on('connect', () => {
104
+ socket.destroy()
105
+ resolve()
106
+ })
107
+ socket.on('error', () => {
108
+ socket.destroy()
109
+ attempts += 1
110
+ if (attempts >= maxAttempts) {
111
+ reject(new Error(`Port ${port} not reachable after ${timeoutMs}ms`))
112
+ return
113
+ }
114
+ setTimeout(check, 100)
115
+ })
116
+ }
117
+
118
+ check()
119
+ })
120
+ }
121
+
122
+ function getAvailablePort(): Promise<number> {
123
+ return new Promise((resolve, reject) => {
124
+ const server = net.createServer()
125
+ server.on('error', reject)
126
+ server.listen(0, LOCAL_HOST, () => {
127
+ const address = server.address()
128
+ if (!address || typeof address === 'string') {
129
+ server.close(() => {
130
+ reject(new Error('Failed to resolve an available port'))
131
+ })
132
+ return
133
+ }
134
+ const port = address.port
135
+ server.close((error) => {
136
+ if (error) {
137
+ reject(error)
138
+ return
139
+ }
140
+ resolve(port)
141
+ })
142
+ })
143
+ })
144
+ }
145
+
146
+ function cleanupSession(session: VscodeSession): void {
147
+ clearTimeout(session.timeoutTimer)
148
+ try {
149
+ session.tunnelClient.close()
150
+ } catch {}
151
+ if (session.coderaftProcess.exitCode === null) {
152
+ try {
153
+ session.coderaftProcess.kill('SIGTERM')
154
+ } catch {}
155
+ }
156
+ }
157
+
158
+ export function getActiveVscodeSession({ sessionKey }: { sessionKey: string }): VscodeSession | undefined {
159
+ return activeSessions.get(sessionKey)
160
+ }
161
+
162
+ export function stopVscode({ sessionKey }: { sessionKey: string }): boolean {
163
+ const session = activeSessions.get(sessionKey)
164
+ if (!session) {
165
+ return false
166
+ }
167
+
168
+ activeSessions.delete(sessionKey)
169
+ cleanupSession(session)
170
+ logger.log(`VS Code stopped (key: ${sessionKey})`)
171
+ return true
172
+ }
173
+
174
+ export async function startVscode({
175
+ sessionKey,
176
+ startedBy,
177
+ workingDirectory,
178
+ }: {
179
+ sessionKey: string
180
+ startedBy: string
181
+ workingDirectory: string
182
+ }): Promise<VscodeSession> {
183
+ const existing = activeSessions.get(sessionKey)
184
+ if (existing) {
185
+ return existing
186
+ }
187
+
188
+ const port = await getAvailablePort()
189
+ const connectionToken = createVscodeConnectionToken()
190
+ const tunnelId = createVscodeTunnelId()
191
+ const args = buildCoderaftArgs({
192
+ port,
193
+ connectionToken,
194
+ workingDirectory,
195
+ })
196
+ const coderaftProcess = spawn('bunx', args, {
197
+ cwd: workingDirectory,
198
+ stdio: ['ignore', 'pipe', 'pipe'],
199
+ env: {
200
+ ...process.env,
201
+ PORT: String(port),
202
+ },
203
+ })
204
+
205
+ coderaftProcess.stdout?.on('data', (data: Buffer) => {
206
+ logger.log(data.toString().trim())
207
+ })
208
+ coderaftProcess.stderr?.on('data', (data: Buffer) => {
209
+ logger.error(data.toString().trim())
210
+ })
211
+
212
+ try {
213
+ await createPortWaiter({
214
+ port,
215
+ process: coderaftProcess,
216
+ timeoutMs: READY_TIMEOUT_MS,
217
+ })
218
+ } catch (error) {
219
+ if (coderaftProcess.exitCode === null) {
220
+ coderaftProcess.kill('SIGTERM')
221
+ }
222
+ throw error
223
+ }
224
+
225
+ const tunnelClient = new TunnelClient({
226
+ localPort: port,
227
+ localHost: LOCAL_HOST,
228
+ tunnelId,
229
+ baseDomain: TUNNEL_BASE_DOMAIN,
230
+ })
231
+
232
+ try {
233
+ await Promise.race([
234
+ tunnelClient.connect(),
235
+ new Promise<never>((_, reject) => {
236
+ setTimeout(() => {
237
+ reject(new Error('Tunnel connection timed out after 15s'))
238
+ }, 15_000)
239
+ }),
240
+ ])
241
+ } catch (error) {
242
+ tunnelClient.close()
243
+ if (coderaftProcess.exitCode === null) {
244
+ coderaftProcess.kill('SIGTERM')
245
+ }
246
+ throw error
247
+ }
248
+
249
+ const url = buildVscodeUrl({
250
+ tunnelUrl: tunnelClient.url,
251
+ connectionToken,
252
+ })
253
+
254
+ const timeoutTimer = setTimeout(() => {
255
+ logger.log(`VS Code auto-stopped after ${MAX_SESSION_MINUTES} minutes (key: ${sessionKey})`)
256
+ stopVscode({ sessionKey })
257
+ }, MAX_SESSION_MS)
258
+ timeoutTimer.unref()
259
+
260
+ const session: VscodeSession = {
261
+ coderaftProcess,
262
+ tunnelClient,
263
+ url,
264
+ workingDirectory,
265
+ startedBy,
266
+ startedAt: Date.now(),
267
+ timeoutTimer,
268
+ }
269
+
270
+ coderaftProcess.once('exit', (code, signal) => {
271
+ const current = activeSessions.get(sessionKey)
272
+ if (current !== session) {
273
+ return
274
+ }
275
+ logger.log(`VS Code process exited (key: ${sessionKey}, code: ${code}, signal: ${signal ?? 'none'})`)
276
+ stopVscode({ sessionKey })
277
+ })
278
+
279
+ activeSessions.set(sessionKey, session)
280
+ logger.log(`VS Code started by ${startedBy}: ${url}`)
281
+ return session
282
+ }
283
+
284
+ export async function handleVscodeCommand({
285
+ command,
286
+ }: CommandContext): Promise<void> {
287
+ const channel = command.channel
288
+ if (!channel) {
289
+ await command.reply({
290
+ content: 'This command can only be used in a channel.',
291
+ flags: SECURE_REPLY_FLAGS,
292
+ })
293
+ return
294
+ }
295
+
296
+ const isThread = [
297
+ ChannelType.PublicThread,
298
+ ChannelType.PrivateThread,
299
+ ChannelType.AnnouncementThread,
300
+ ].includes(channel.type)
301
+ const isTextChannel = channel.type === ChannelType.GuildText
302
+ if (!isThread && !isTextChannel) {
303
+ await command.reply({
304
+ content: 'This command can only be used in a text channel or thread.',
305
+ flags: SECURE_REPLY_FLAGS,
306
+ })
307
+ return
308
+ }
309
+
310
+ const resolved = await resolveWorkingDirectory({
311
+ channel: channel as TextChannel | ThreadChannel,
312
+ })
313
+ if (!resolved) {
314
+ await command.reply({
315
+ content: 'Could not determine project directory for this channel.',
316
+ flags: SECURE_REPLY_FLAGS,
317
+ })
318
+ return
319
+ }
320
+
321
+ await command.deferReply({ flags: SECURE_REPLY_FLAGS })
322
+
323
+ const sessionKey = channel.id
324
+ const existing = getActiveVscodeSession({ sessionKey })
325
+ if (existing) {
326
+ await command.editReply({
327
+ content:
328
+ `VS Code is already running for this thread. ` +
329
+ `It auto-stops after ${MAX_SESSION_MINUTES} minutes from startup.\n` +
330
+ `${existing.url}`,
331
+ })
332
+ return
333
+ }
334
+
335
+ try {
336
+ const session = await startVscode({
337
+ sessionKey,
338
+ startedBy: command.user.tag,
339
+ workingDirectory: resolved.workingDirectory,
340
+ })
341
+ await command.editReply({
342
+ content:
343
+ `VS Code started for \`${session.workingDirectory}\`. ` +
344
+ `This private link auto-stops after ${MAX_SESSION_MINUTES} minutes, so open it before it expires.\n` +
345
+ `${session.url}`,
346
+ })
347
+ } catch (error) {
348
+ logger.error('Failed to start VS Code:', error)
349
+ await command.editReply({
350
+ content: `Failed to start VS Code: ${error instanceof Error ? error.message : String(error)}`,
351
+ })
352
+ }
353
+ }
354
+
355
+ export function cleanupAllVscodeSessions(): void {
356
+ for (const sessionKey of activeSessions.keys()) {
357
+ stopVscode({ sessionKey })
358
+ }
359
+ }
360
+
361
+ function onProcessExit(): void {
362
+ cleanupAllVscodeSessions()
363
+ }
364
+
365
+ process.on('SIGINT', onProcessExit)
366
+ process.on('SIGTERM', onProcessExit)
367
+ process.on('exit', onProcessExit)
@@ -182,7 +182,7 @@ export async function registerCommands({
182
182
  new SlashCommandBuilder()
183
183
  .setName('new-worktree')
184
184
  .setDescription(
185
- truncateCommandDescription('Create a git worktree branch from origin/HEAD (or main). Optionally pick a base branch.'),
185
+ truncateCommandDescription('Create a git worktree branch from HEAD by default. Optionally pick a base branch.'),
186
186
  )
187
187
  .addStringOption((option) => {
188
188
  option
@@ -198,7 +198,7 @@ export async function registerCommands({
198
198
  option
199
199
  .setName('base-branch')
200
200
  .setDescription(
201
- truncateCommandDescription('Branch to create the worktree from (default: origin/HEAD or main)'),
201
+ truncateCommandDescription('Branch to create the worktree from (default: HEAD)'),
202
202
  )
203
203
  .setRequired(false)
204
204
  .setAutocomplete(true)
@@ -488,6 +488,13 @@ export async function registerCommands({
488
488
  .setDescription(truncateCommandDescription('Stop screen sharing'))
489
489
  .setDMPermission(false)
490
490
  .toJSON(),
491
+ new SlashCommandBuilder()
492
+ .setName('vscode')
493
+ .setDescription(
494
+ truncateCommandDescription('Open VS Code in the browser for this project or worktree (auto-stops after 30 minutes)'),
495
+ )
496
+ .setDMPermission(false)
497
+ .toJSON(),
491
498
  ]
492
499
 
493
500
  // Dynamic commands are registered in priority order: agents → user commands → skills → MCP prompts.