@otto-assistant/bridge 0.4.96 → 0.4.100

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 (44) hide show
  1. package/dist/agent-model.e2e.test.js +7 -1
  2. package/dist/anthropic-account-identity.js +62 -0
  3. package/dist/anthropic-account-identity.test.js +38 -0
  4. package/dist/anthropic-auth-plugin.js +72 -12
  5. package/dist/anthropic-auth-state.js +28 -3
  6. package/dist/{anthropic-auth-plugin.test.js → anthropic-auth-state.test.js} +21 -2
  7. package/dist/cli-parsing.test.js +12 -9
  8. package/dist/cli-send-thread.e2e.test.js +4 -7
  9. package/dist/cli.js +25 -12
  10. package/dist/commands/screenshare.js +1 -1
  11. package/dist/commands/screenshare.test.js +2 -2
  12. package/dist/commands/vscode.js +269 -0
  13. package/dist/db.js +1 -0
  14. package/dist/discord-command-registration.js +7 -2
  15. package/dist/gateway-proxy-reconnect.e2e.test.js +2 -2
  16. package/dist/interaction-handler.js +4 -0
  17. package/dist/system-message.js +24 -23
  18. package/dist/system-message.test.js +24 -23
  19. package/dist/system-prompt-drift-plugin.js +41 -11
  20. package/dist/utils.js +1 -1
  21. package/dist/worktrees.js +0 -33
  22. package/package.json +1 -1
  23. package/src/agent-model.e2e.test.ts +8 -1
  24. package/src/anthropic-account-identity.test.ts +52 -0
  25. package/src/anthropic-account-identity.ts +77 -0
  26. package/src/anthropic-auth-plugin.ts +82 -12
  27. package/src/{anthropic-auth-plugin.test.ts → anthropic-auth-state.test.ts} +23 -1
  28. package/src/anthropic-auth-state.ts +36 -3
  29. package/src/cli-parsing.test.ts +16 -9
  30. package/src/cli-send-thread.e2e.test.ts +6 -7
  31. package/src/cli.ts +31 -13
  32. package/src/commands/screenshare.test.ts +2 -2
  33. package/src/commands/screenshare.ts +1 -1
  34. package/src/commands/vscode.ts +342 -0
  35. package/src/db.ts +1 -0
  36. package/src/discord-command-registration.ts +9 -2
  37. package/src/gateway-proxy-reconnect.e2e.test.ts +2 -2
  38. package/src/interaction-handler.ts +5 -0
  39. package/src/system-message.test.ts +24 -23
  40. package/src/system-message.ts +24 -23
  41. package/src/system-prompt-drift-plugin.ts +48 -12
  42. package/src/utils.ts +1 -1
  43. package/src/worktrees.test.ts +1 -0
  44. 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
  })
@@ -339,14 +339,13 @@ describe('kimaki send --channel thread creation', () => {
339
339
  })
340
340
 
341
341
  const allContent = botReplies.map((m) => {
342
- return m.content.slice(0, 200)
342
+ return m.content
343
343
  })
344
- expect(allContent).toMatchInlineSnapshot(`
345
- [
346
- "✗ opencode session error: Command not found: "hello-test". Available commands: init, review, goke, security-review, jitter, proxyman, gitchamber, event-sourcing-state, usecomputer, spiceflow, batch, x",
347
- "✗ OpenCode API error: Command not found: "hello-test". Available commands: init, review, goke, security-review, jitter, proxyman, gitchamber, event-sourcing-state, usecomputer, spiceflow, batch, x-art",
348
- ]
349
- `)
344
+ expect(
345
+ allContent.some((content) => {
346
+ return content.includes('Command not found: "hello-test"')
347
+ }),
348
+ ).toBe(true)
350
349
  } finally {
351
350
  store.setState({ registeredUserCommands: prevCommands })
352
351
  }
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,342 @@
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 READY_TIMEOUT_MS = 60_000
25
+ const LOCAL_HOST = '127.0.0.1'
26
+
27
+ export type VscodeSession = {
28
+ coderaftProcess: ChildProcess
29
+ tunnelClient: TunnelClient
30
+ url: string
31
+ workingDirectory: string
32
+ startedBy: string
33
+ startedAt: number
34
+ timeoutTimer: ReturnType<typeof setTimeout>
35
+ }
36
+
37
+ const activeSessions = new Map<string, VscodeSession>()
38
+
39
+ export function createVscodeTunnelId(): string {
40
+ return crypto.randomBytes(TUNNEL_ID_BYTES).toString('hex')
41
+ }
42
+
43
+ export function buildCoderaftArgs({
44
+ port,
45
+ workingDirectory,
46
+ }: {
47
+ port: number
48
+ workingDirectory: string
49
+ }): string[] {
50
+ return [
51
+ 'coderaft',
52
+ '--port',
53
+ String(port),
54
+ '--host',
55
+ LOCAL_HOST,
56
+ '--without-connection-token',
57
+ '--disable-workspace-trust',
58
+ '--default-folder',
59
+ workingDirectory,
60
+ ]
61
+ }
62
+
63
+ function createPortWaiter({
64
+ port,
65
+ process: proc,
66
+ timeoutMs,
67
+ }: {
68
+ port: number
69
+ process: ChildProcess
70
+ timeoutMs: number
71
+ }): Promise<void> {
72
+ return new Promise((resolve, reject) => {
73
+ const maxAttempts = Math.ceil(timeoutMs / 100)
74
+ let attempts = 0
75
+
76
+ const check = (): void => {
77
+ if (proc.exitCode !== null) {
78
+ reject(new Error(`coderaft exited with code ${proc.exitCode} before becoming ready`))
79
+ return
80
+ }
81
+
82
+ const socket = net.createConnection(port, LOCAL_HOST)
83
+ socket.on('connect', () => {
84
+ socket.destroy()
85
+ resolve()
86
+ })
87
+ socket.on('error', () => {
88
+ socket.destroy()
89
+ attempts += 1
90
+ if (attempts >= maxAttempts) {
91
+ reject(new Error(`Port ${port} not reachable after ${timeoutMs}ms`))
92
+ return
93
+ }
94
+ setTimeout(check, 100)
95
+ })
96
+ }
97
+
98
+ check()
99
+ })
100
+ }
101
+
102
+ function getAvailablePort(): Promise<number> {
103
+ return new Promise((resolve, reject) => {
104
+ const server = net.createServer()
105
+ server.on('error', reject)
106
+ server.listen(0, LOCAL_HOST, () => {
107
+ const address = server.address()
108
+ if (!address || typeof address === 'string') {
109
+ server.close(() => {
110
+ reject(new Error('Failed to resolve an available port'))
111
+ })
112
+ return
113
+ }
114
+ const port = address.port
115
+ server.close((error) => {
116
+ if (error) {
117
+ reject(error)
118
+ return
119
+ }
120
+ resolve(port)
121
+ })
122
+ })
123
+ })
124
+ }
125
+
126
+ function cleanupSession(session: VscodeSession): void {
127
+ clearTimeout(session.timeoutTimer)
128
+ try {
129
+ session.tunnelClient.close()
130
+ } catch {}
131
+ if (session.coderaftProcess.exitCode === null) {
132
+ try {
133
+ session.coderaftProcess.kill('SIGTERM')
134
+ } catch {}
135
+ }
136
+ }
137
+
138
+ export function getActiveVscodeSession({ sessionKey }: { sessionKey: string }): VscodeSession | undefined {
139
+ return activeSessions.get(sessionKey)
140
+ }
141
+
142
+ export function stopVscode({ sessionKey }: { sessionKey: string }): boolean {
143
+ const session = activeSessions.get(sessionKey)
144
+ if (!session) {
145
+ return false
146
+ }
147
+
148
+ activeSessions.delete(sessionKey)
149
+ cleanupSession(session)
150
+ logger.log(`VS Code stopped (key: ${sessionKey})`)
151
+ return true
152
+ }
153
+
154
+ export async function startVscode({
155
+ sessionKey,
156
+ startedBy,
157
+ workingDirectory,
158
+ }: {
159
+ sessionKey: string
160
+ startedBy: string
161
+ workingDirectory: string
162
+ }): Promise<VscodeSession> {
163
+ const existing = activeSessions.get(sessionKey)
164
+ if (existing) {
165
+ return existing
166
+ }
167
+
168
+ const port = await getAvailablePort()
169
+ const tunnelId = createVscodeTunnelId()
170
+ const args = buildCoderaftArgs({
171
+ port,
172
+ workingDirectory,
173
+ })
174
+ const coderaftProcess = spawn('bunx', args, {
175
+ cwd: workingDirectory,
176
+ stdio: ['ignore', 'pipe', 'pipe'],
177
+ env: {
178
+ ...process.env,
179
+ PORT: String(port),
180
+ },
181
+ })
182
+
183
+ coderaftProcess.stdout?.on('data', (data: Buffer) => {
184
+ logger.log(data.toString().trim())
185
+ })
186
+ coderaftProcess.stderr?.on('data', (data: Buffer) => {
187
+ logger.error(data.toString().trim())
188
+ })
189
+
190
+ try {
191
+ await createPortWaiter({
192
+ port,
193
+ process: coderaftProcess,
194
+ timeoutMs: READY_TIMEOUT_MS,
195
+ })
196
+ } catch (error) {
197
+ if (coderaftProcess.exitCode === null) {
198
+ coderaftProcess.kill('SIGTERM')
199
+ }
200
+ throw error
201
+ }
202
+
203
+ const tunnelClient = new TunnelClient({
204
+ localPort: port,
205
+ localHost: LOCAL_HOST,
206
+ tunnelId,
207
+ baseDomain: TUNNEL_BASE_DOMAIN,
208
+ })
209
+
210
+ try {
211
+ await Promise.race([
212
+ tunnelClient.connect(),
213
+ new Promise<never>((_, reject) => {
214
+ setTimeout(() => {
215
+ reject(new Error('Tunnel connection timed out after 15s'))
216
+ }, 15_000)
217
+ }),
218
+ ])
219
+ } catch (error) {
220
+ tunnelClient.close()
221
+ if (coderaftProcess.exitCode === null) {
222
+ coderaftProcess.kill('SIGTERM')
223
+ }
224
+ throw error
225
+ }
226
+
227
+ const url = tunnelClient.url
228
+
229
+ const timeoutTimer = setTimeout(() => {
230
+ logger.log(`VS Code auto-stopped after ${MAX_SESSION_MINUTES} minutes (key: ${sessionKey})`)
231
+ stopVscode({ sessionKey })
232
+ }, MAX_SESSION_MS)
233
+ timeoutTimer.unref()
234
+
235
+ const session: VscodeSession = {
236
+ coderaftProcess,
237
+ tunnelClient,
238
+ url,
239
+ workingDirectory,
240
+ startedBy,
241
+ startedAt: Date.now(),
242
+ timeoutTimer,
243
+ }
244
+
245
+ coderaftProcess.once('exit', (code, signal) => {
246
+ const current = activeSessions.get(sessionKey)
247
+ if (current !== session) {
248
+ return
249
+ }
250
+ logger.log(`VS Code process exited (key: ${sessionKey}, code: ${code}, signal: ${signal ?? 'none'})`)
251
+ stopVscode({ sessionKey })
252
+ })
253
+
254
+ activeSessions.set(sessionKey, session)
255
+ logger.log(`VS Code started by ${startedBy}: ${url}`)
256
+ return session
257
+ }
258
+
259
+ export async function handleVscodeCommand({
260
+ command,
261
+ }: CommandContext): Promise<void> {
262
+ const channel = command.channel
263
+ if (!channel) {
264
+ await command.reply({
265
+ content: 'This command can only be used in a channel.',
266
+ flags: SECURE_REPLY_FLAGS,
267
+ })
268
+ return
269
+ }
270
+
271
+ const isThread = [
272
+ ChannelType.PublicThread,
273
+ ChannelType.PrivateThread,
274
+ ChannelType.AnnouncementThread,
275
+ ].includes(channel.type)
276
+ const isTextChannel = channel.type === ChannelType.GuildText
277
+ if (!isThread && !isTextChannel) {
278
+ await command.reply({
279
+ content: 'This command can only be used in a text channel or thread.',
280
+ flags: SECURE_REPLY_FLAGS,
281
+ })
282
+ return
283
+ }
284
+
285
+ const resolved = await resolveWorkingDirectory({
286
+ channel: channel as TextChannel | ThreadChannel,
287
+ })
288
+ if (!resolved) {
289
+ await command.reply({
290
+ content: 'Could not determine project directory for this channel.',
291
+ flags: SECURE_REPLY_FLAGS,
292
+ })
293
+ return
294
+ }
295
+
296
+ await command.deferReply({ flags: SECURE_REPLY_FLAGS })
297
+
298
+ const sessionKey = channel.id
299
+ const existing = getActiveVscodeSession({ sessionKey })
300
+ if (existing) {
301
+ await command.editReply({
302
+ content:
303
+ `VS Code is already running for this thread. ` +
304
+ `This unique tunnel auto-stops after ${MAX_SESSION_MINUTES} minutes from startup.\n` +
305
+ `${existing.url}`,
306
+ })
307
+ return
308
+ }
309
+
310
+ try {
311
+ const session = await startVscode({
312
+ sessionKey,
313
+ startedBy: command.user.tag,
314
+ workingDirectory: resolved.workingDirectory,
315
+ })
316
+ await command.editReply({
317
+ content:
318
+ `VS Code started for \`${session.workingDirectory}\`. ` +
319
+ `This unique tunnel auto-stops after ${MAX_SESSION_MINUTES} minutes, so open it before it expires.\n` +
320
+ `${session.url}`,
321
+ })
322
+ } catch (error) {
323
+ logger.error('Failed to start VS Code:', error)
324
+ await command.editReply({
325
+ content: `Failed to start VS Code: ${error instanceof Error ? error.message : String(error)}`,
326
+ })
327
+ }
328
+ }
329
+
330
+ export function cleanupAllVscodeSessions(): void {
331
+ for (const sessionKey of activeSessions.keys()) {
332
+ stopVscode({ sessionKey })
333
+ }
334
+ }
335
+
336
+ function onProcessExit(): void {
337
+ cleanupAllVscodeSessions()
338
+ }
339
+
340
+ process.on('SIGINT', onProcessExit)
341
+ process.on('SIGTERM', onProcessExit)
342
+ process.on('exit', onProcessExit)
package/src/db.ts CHANGED
@@ -235,6 +235,7 @@ async function migrateSchema(prisma: PrismaClient): Promise<void> {
235
235
  // Also fix NULL worktree status rows that predate the required enum.
236
236
  const defensiveMigrations = [
237
237
  "UPDATE bot_tokens SET bot_mode = 'self_hosted' WHERE bot_mode = 'self-hosted'",
238
+ "UPDATE bot_tokens SET proxy_url = REPLACE(proxy_url, 'discord-gateway.kimaki.xyz', 'discord-gateway.kimaki.dev') WHERE bot_mode = 'gateway' AND proxy_url LIKE '%discord-gateway.kimaki.xyz%'",
238
239
  "UPDATE thread_worktrees SET status = 'pending' WHERE status IS NULL",
239
240
  ]
240
241
  for (const stmt of defensiveMigrations) {