kimaki 1.1.0 → 1.3.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.
Files changed (76) hide show
  1. package/dist/bot.d.ts +6 -1
  2. package/dist/bot.js +9 -3
  3. package/dist/bot.js.map +1 -1
  4. package/dist/cli/bot.js +13 -5
  5. package/dist/cli/bot.js.map +1 -1
  6. package/dist/cli-lock.e2e.test.js +4 -2
  7. package/dist/cli-lock.e2e.test.js.map +1 -1
  8. package/dist/cli-sessions.e2e.test.js +2 -4
  9. package/dist/cli-sessions.e2e.test.js.map +1 -1
  10. package/dist/commands/worktree-commands.d.ts +1 -1
  11. package/dist/event-loop.d.ts +2 -1
  12. package/dist/event-loop.js +2 -2
  13. package/dist/event-loop.js.map +1 -1
  14. package/dist/hello-thread.e2e.test.js +31 -0
  15. package/dist/hello-thread.e2e.test.js.map +1 -1
  16. package/dist/ingress.d.ts +1 -1
  17. package/dist/ingress.js +25 -17
  18. package/dist/ingress.js.map +1 -1
  19. package/dist/lock-routes.d.ts +13 -13
  20. package/dist/main.d.ts +2 -0
  21. package/dist/main.js +3 -1
  22. package/dist/main.js.map +1 -1
  23. package/dist/plugin/index.js +42 -9
  24. package/dist/plugin/index.js.map +1 -1
  25. package/dist/scheduler.d.ts +1 -1
  26. package/dist/session-events.d.ts +1 -1
  27. package/dist/sessions.d.ts +5 -5
  28. package/dist/sessions.js +3 -3
  29. package/dist/sessions.js.map +1 -1
  30. package/dist/slash-commands.js +1 -1
  31. package/dist/slash-commands.js.map +1 -1
  32. package/dist/system-prompt.d.ts +14 -0
  33. package/dist/system-prompt.js +34 -0
  34. package/dist/system-prompt.js.map +1 -1
  35. package/dist/test/harness.d.ts +2 -1
  36. package/dist/test/harness.js +2 -1
  37. package/dist/test/harness.js.map +1 -1
  38. package/dist/test/replay.js +1 -0
  39. package/dist/test/replay.js.map +1 -1
  40. package/dist/thread-reducer.d.ts +3 -0
  41. package/dist/thread-reducer.fixtures.test.js +2 -2
  42. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  43. package/dist/thread-reducer.js +32 -4
  44. package/dist/thread-reducer.js.map +1 -1
  45. package/dist/thread-reducer.test.js +31 -0
  46. package/dist/thread-reducer.test.js.map +1 -1
  47. package/dist/tunnel.d.ts +12 -0
  48. package/dist/tunnel.js +207 -0
  49. package/dist/tunnel.js.map +1 -0
  50. package/dist/tunnel.test.d.ts +1 -0
  51. package/dist/tunnel.test.js +38 -0
  52. package/dist/tunnel.test.js.map +1 -0
  53. package/dist/voice-call.js +1 -1
  54. package/dist/voice-call.js.map +1 -1
  55. package/package.json +5 -5
  56. package/skills/kimaki-tunnel/SKILL.md +14 -10
  57. package/src/bot.ts +16 -4
  58. package/src/cli/bot.ts +12 -5
  59. package/src/cli-lock.e2e.test.ts +4 -2
  60. package/src/event-loop.ts +4 -1
  61. package/src/hello-thread.e2e.test.ts +35 -0
  62. package/src/ingress.ts +25 -16
  63. package/src/main.ts +6 -1
  64. package/src/message-tool.e2e.test.ts +45 -0
  65. package/src/plugin/index.ts +42 -9
  66. package/src/sessions.ts +3 -2
  67. package/src/slash-commands.ts +1 -1
  68. package/src/system-prompt.ts +38 -0
  69. package/src/test/harness.ts +3 -0
  70. package/src/test/replay.ts +1 -0
  71. package/src/thread-reducer.fixtures.test.ts +2 -2
  72. package/src/thread-reducer.test.ts +31 -0
  73. package/src/thread-reducer.ts +36 -4
  74. package/src/tunnel.test.ts +39 -0
  75. package/src/tunnel.ts +209 -0
  76. package/src/voice-call.ts +1 -1
package/src/tunnel.ts ADDED
@@ -0,0 +1,209 @@
1
+ // `kimaki tunnel`: run a command, find the local port it listens on, and expose
2
+ // it at https://<route>.<id>.opentunnel.xyz. TLS ends in this process, so the
3
+ // relay never sees plaintext. The tunnel identity is the default opentunnel
4
+ // profile in $XDG_DATA_HOME/opentunnel, shared with the opentunnel CLI.
5
+
6
+ import crypto from 'node:crypto'
7
+ import { execFile, spawn, type ChildProcess } from 'node:child_process'
8
+ import net from 'node:net'
9
+ import { setTimeout as sleep } from 'node:timers/promises'
10
+ import { promisify, stripVTControlCharacters } from 'node:util'
11
+ import { create as createOpenTunnel, type OpenTunnelConnection } from '@opentunnel/client'
12
+
13
+ const execFileAsync = promisify(execFile)
14
+
15
+ const ROUTE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
16
+ const PORT_WAIT_MS = 30_000
17
+ const ATTACH_TIMEOUT_MS = 60_000
18
+ const CHILD_KILL_GRACE_MS = 5_000
19
+
20
+ // Only lines with host:port, so "Port 5173 is in use" does not match.
21
+ const LOCAL_PORT_PATTERN = /(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::\]):(\d{1,5})/i
22
+ const IGNORED_LINE_PATTERNS = [/\bdebugger (?:listening|attached)\b/i, /\bdevtools listening\b/i]
23
+
24
+ export function detectPortFromText(text: string): number | null {
25
+ // Vite colors the port separately, so strip ANSI before matching.
26
+ for (const line of stripVTControlCharacters(text).split('\n')) {
27
+ if (IGNORED_LINE_PATTERNS.some((pattern) => pattern.test(line))) continue
28
+ const port = Number(line.match(LOCAL_PORT_PATTERN)?.[1])
29
+ if (port >= 1 && port <= 65_535) return port
30
+ }
31
+ return null
32
+ }
33
+
34
+ // Explicit name, then the tuistory session ID (stable URL across restarts of one session), then random.
35
+ export function routeName({ name, tuistorySessionId }: { name?: string; tuistorySessionId?: string }): string | Error {
36
+ if (name) return ROUTE_NAME.test(name) ? name : new Error(`Invalid tunnel ID ${JSON.stringify(name)}: use lowercase letters, digits and dashes`)
37
+ const fromSession = tuistorySessionId?.toLowerCase()
38
+ if (fromSession && ROUTE_NAME.test(fromSession)) return fromSession
39
+ return crypto.randomBytes(8).toString('hex')
40
+ }
41
+
42
+ function isPortInUse({ port, host }: { port: number; host: string }): Promise<boolean> {
43
+ return new Promise((resolve) => {
44
+ const socket = net.connect({ port, host })
45
+ socket.once('connect', () => { socket.destroy(); resolve(true) })
46
+ socket.once('error', () => { socket.destroy(); resolve(false) })
47
+ })
48
+ }
49
+
50
+ async function waitForPort({ port, host, signal }: { port: number; host: string; signal: AbortSignal }): Promise<void | Error> {
51
+ while (!(await isPortInUse({ port, host }))) {
52
+ if (signal.aborted) return new Error(`Timed out waiting for ${host}:${port} to accept connections`)
53
+ await sleep(300)
54
+ }
55
+ }
56
+
57
+ async function listeningPids(port: number): Promise<number[]> {
58
+ const { stdout } = await execFileAsync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { timeout: 5_000 }).catch(() => ({ stdout: '' }))
59
+ return stdout.split('\n').map(Number).filter((pid) => Number.isInteger(pid) && pid > 0)
60
+ }
61
+
62
+ async function killProcessOnPort({ port, host }: { port: number; host: string }): Promise<void | Error> {
63
+ for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
64
+ for (const pid of await listeningPids(port)) {
65
+ try { process.kill(pid, signal) } catch {}
66
+ }
67
+ for (let waited = 0; waited < 3_000; waited += 200) {
68
+ if (!(await isPortInUse({ port, host }))) return
69
+ await sleep(200)
70
+ }
71
+ }
72
+ return new Error(`Port ${port} is still in use after --kill`)
73
+ }
74
+
75
+ function detectPortFromOutput({ child, signal }: { child: ChildProcess; signal: AbortSignal }): Promise<number | Error> {
76
+ return new Promise((resolve) => {
77
+ let buffer = ''
78
+ const finish = (result: number | Error) => {
79
+ child.stdout?.off('data', onData)
80
+ child.stderr?.off('data', onData)
81
+ child.off('exit', onExit)
82
+ signal.removeEventListener('abort', onAbort)
83
+ resolve(result)
84
+ }
85
+ const onData = (chunk: Buffer) => {
86
+ buffer = (buffer + chunk.toString()).slice(-8_000)
87
+ const port = detectPortFromText(buffer)
88
+ if (port) finish(port)
89
+ }
90
+ const onExit = (code: number | null) => finish(new Error(`Command exited with code ${code} before printing a localhost URL. Pass --port`))
91
+ const onAbort = () => finish(new Error(`No localhost URL in the command output after ${PORT_WAIT_MS / 1000}s. Pass --port`))
92
+ child.stdout?.on('data', onData)
93
+ child.stderr?.on('data', onData)
94
+ child.once('exit', onExit)
95
+ signal.addEventListener('abort', onAbort, { once: true })
96
+ })
97
+ }
98
+
99
+ function killChild(child: ChildProcess | undefined, signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
100
+ if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
101
+ return new Promise((resolve) => {
102
+ const timer = setTimeout(() => { child.kill('SIGKILL'); resolve() }, CHILD_KILL_GRACE_MS)
103
+ child.once('exit', () => { clearTimeout(timer); resolve() })
104
+ child.kill(signal)
105
+ })
106
+ }
107
+
108
+ const log = (line: string) => process.stderr.write(`${line}\n`)
109
+
110
+ export async function runTunnel(options: {
111
+ port?: number
112
+ command?: string[]
113
+ name?: string
114
+ host?: string
115
+ kill?: boolean
116
+ }): Promise<void | Error> {
117
+ const host = options.host ?? 'localhost'
118
+ const route = routeName({ name: options.name, tuistorySessionId: process.env['TUISTORY_SESSION_ID'] })
119
+ if (route instanceof Error) return route
120
+ if (options.kill && !options.port) return new Error('--kill needs --port')
121
+
122
+ if (options.port && options.kill) {
123
+ const killed = await killProcessOnPort({ port: options.port, host })
124
+ if (killed instanceof Error) return killed
125
+ } else if (options.port && options.command && (await isPortInUse({ port: options.port, host }))) {
126
+ return new Error(`Port ${options.port} is already in use. Pass --kill to stop the process on it first`)
127
+ }
128
+
129
+ const client = createOpenTunnel()
130
+ const existing = await client.tunnel.get().catch(() => undefined)
131
+ if (!existing) log('Creating the opentunnel.xyz tunnel of this machine (waits for a certificate, up to a minute)...')
132
+ const identity = await client.tunnel.ensure().catch((cause: unknown) => new Error('Could not create the opentunnel tunnel', { cause }))
133
+ if (identity instanceof Error) return identity
134
+ const url = `https://${route}.${identity.hostname}`
135
+
136
+ let child: ChildProcess | undefined
137
+ let connection: OpenTunnelConnection | undefined
138
+ // Set before we kill the child ourselves, so its exit does not end the process first.
139
+ let stopping = false
140
+ const shutdown = async (signal: NodeJS.Signals) => {
141
+ if (stopping) return
142
+ stopping = true
143
+ log('Shutting down...')
144
+ await connection?.close().catch(() => undefined)
145
+ await killChild(child, signal)
146
+ process.exit(0)
147
+ }
148
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.once(signal, () => void shutdown(signal))
149
+
150
+ if (options.command) {
151
+ const [cmd, ...args] = options.command
152
+ child = spawn(cmd!, args, {
153
+ stdio: ['inherit', 'pipe', 'pipe'],
154
+ env: {
155
+ ...process.env,
156
+ ...(options.port && { PORT: String(options.port) }),
157
+ KIMAKI_TUNNEL_URL: url,
158
+ // Old name from traforo, kept so existing app configs still work.
159
+ TRAFORO_URL: url,
160
+ // wrangler dev passes parent env vars as worker bindings, so workers can read KIMAKI_TUNNEL_URL.
161
+ CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true',
162
+ FORCE_COLOR: '1',
163
+ VITE_CLS: 'false',
164
+ NEXT_TELEMETRY_DISABLED: '1',
165
+ },
166
+ })
167
+ child.stdout?.pipe(process.stdout)
168
+ child.stderr?.pipe(process.stderr)
169
+ child.on('error', (error) => { log(`Failed to start command: ${error.message}`); process.exit(1) })
170
+ child.on('exit', (code, signal) => {
171
+ if (stopping) return
172
+ log(`Command exited with ${signal ?? `code ${code}`}`)
173
+ process.exit(code ?? 1)
174
+ })
175
+ }
176
+
177
+ const fail = async (error: Error) => { stopping = true; await killChild(child); return error }
178
+
179
+ let port = options.port
180
+ const portSignal = AbortSignal.timeout(PORT_WAIT_MS)
181
+ if (!port && child) {
182
+ const detected = await detectPortFromOutput({ child, signal: portSignal })
183
+ if (detected instanceof Error) return fail(detected)
184
+ port = detected
185
+ }
186
+ if (!port) return fail(new Error('Could not find the local port. Pass --port'))
187
+ const ready = await waitForPort({ port, host, signal: portSignal })
188
+ if (ready instanceof Error) return fail(ready)
189
+
190
+ // connect retries route_conflict forever, so a route held by another process would hang here.
191
+ const connecting = client.tunnel.connect({ routes: { [route]: `${host}:${port}` } })
192
+ const timeout = sleep(ATTACH_TIMEOUT_MS, null, { ref: false })
193
+ const attached = await Promise.race([connecting, timeout]).catch((cause: unknown) => new Error('Could not connect the tunnel', { cause }))
194
+ if (attached instanceof Error) return fail(attached)
195
+ if (!attached) return fail(new Error(`Tunnel did not attach in ${ATTACH_TIMEOUT_MS / 1000}s. Another process may use the tunnel ID ${route}`))
196
+ connection = attached
197
+
198
+ process.stdout.write(`\nLocal: http://${host}:${port}\nTunnel: ${url}\n\n`)
199
+
200
+ void (async () => {
201
+ for await (const event of attached.events) {
202
+ if (event.type === 'disconnected') log(`Tunnel disconnected: ${event.reason}`)
203
+ if (event.type === 'connected') log('Tunnel connected')
204
+ }
205
+ })()
206
+
207
+ const closed = await attached.closed.then(() => undefined, (cause: unknown) => new Error('Tunnel stopped', { cause }))
208
+ return fail(closed ?? new Error('Tunnel stopped'))
209
+ }
package/src/voice-call.ts CHANGED
@@ -349,7 +349,7 @@ export function createVoiceCalls({ opencodeConfigDir }: { opencodeConfigDir: str
349
349
  const states = guild.voiceStates.cache.filter((state) => state.channelId === channelId && state.id !== bot.discord.user?.id)
350
350
  const users = new Map<string, Author>()
351
351
  for (const state of states.values()) {
352
- if (!(await canUseKimaki({ guild, userId: state.id }))) continue
352
+ if (!(await canUseKimaki(bot, { guild, userId: state.id }))) continue
353
353
  const user = await bot.discord.users.fetch(state.id).catch(() => null)
354
354
  users.set(state.id, { id: state.id, username: user?.username ?? state.id })
355
355
  }