kimaki 1.2.0 → 1.4.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 (63) hide show
  1. package/dist/bot.d.ts +4 -2
  2. package/dist/bot.js.map +1 -1
  3. package/dist/builtin-skills.d.ts +3 -0
  4. package/dist/builtin-skills.js +29 -0
  5. package/dist/builtin-skills.js.map +1 -1
  6. package/dist/builtin-skills.test.js +73 -1
  7. package/dist/builtin-skills.test.js.map +1 -1
  8. package/dist/cli/bot.d.ts +1 -1
  9. package/dist/cli/bot.js +15 -6
  10. package/dist/cli/bot.js.map +1 -1
  11. package/dist/cli-lock.e2e.test.js +3 -2
  12. package/dist/cli-lock.e2e.test.js.map +1 -1
  13. package/dist/commands/login-commands.d.ts +1 -1
  14. package/dist/commands/preference-commands.d.ts +1 -1
  15. package/dist/commands/worktree-commands.d.ts +1 -1
  16. package/dist/hello-thread.e2e.test.js +31 -0
  17. package/dist/hello-thread.e2e.test.js.map +1 -1
  18. package/dist/ingress.js +11 -6
  19. package/dist/ingress.js.map +1 -1
  20. package/dist/lock-routes.d.ts +14 -14
  21. package/dist/main.d.ts +2 -0
  22. package/dist/main.js +1 -0
  23. package/dist/main.js.map +1 -1
  24. package/dist/markdown/markdown.test.js +10 -0
  25. package/dist/markdown/markdown.test.js.map +1 -1
  26. package/dist/markdown/render-markdown.js +3 -1
  27. package/dist/markdown/render-markdown.js.map +1 -1
  28. package/dist/plugin/index.js +8 -2
  29. package/dist/plugin/index.js.map +1 -1
  30. package/dist/prompt.d.ts +6 -6
  31. package/dist/scheduler.d.ts +1 -1
  32. package/dist/session-events.js +9 -5
  33. package/dist/session-events.js.map +1 -1
  34. package/dist/sessions.d.ts +10 -10
  35. package/dist/sessions.js +1 -0
  36. package/dist/sessions.js.map +1 -1
  37. package/dist/system-prompt.d.ts +3 -1
  38. package/dist/system-prompt.js +52 -43
  39. package/dist/system-prompt.js.map +1 -1
  40. package/dist/tunnel.d.ts +21 -0
  41. package/dist/tunnel.js +296 -0
  42. package/dist/tunnel.js.map +1 -0
  43. package/dist/tunnel.test.d.ts +1 -0
  44. package/dist/tunnel.test.js +114 -0
  45. package/dist/tunnel.test.js.map +1 -0
  46. package/package.json +3 -3
  47. package/skills/kimaki-tunnel/SKILL.md +16 -11
  48. package/src/bot.ts +3 -0
  49. package/src/builtin-skills.test.ts +74 -1
  50. package/src/builtin-skills.ts +33 -0
  51. package/src/cli/bot.ts +13 -7
  52. package/src/cli-lock.e2e.test.ts +3 -2
  53. package/src/hello-thread.e2e.test.ts +35 -0
  54. package/src/ingress.ts +9 -5
  55. package/src/main.ts +4 -0
  56. package/src/markdown/markdown.test.ts +11 -0
  57. package/src/markdown/render-markdown.ts +3 -1
  58. package/src/plugin/index.ts +9 -2
  59. package/src/session-events.ts +8 -4
  60. package/src/sessions.ts +1 -0
  61. package/src/system-prompt.ts +56 -42
  62. package/src/tunnel.test.ts +115 -0
  63. package/src/tunnel.ts +308 -0
package/src/tunnel.ts ADDED
@@ -0,0 +1,308 @@
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 http from 'node:http'
9
+ import net from 'node:net'
10
+ import { setTimeout as sleep } from 'node:timers/promises'
11
+ import { promisify, stripVTControlCharacters } from 'node:util'
12
+ import {
13
+ create as createOpenTunnel,
14
+ isValidRouteName,
15
+ OpenTunnelClientError,
16
+ publicUrl,
17
+ type OpenTunnelClientEvent,
18
+ type OpenTunnelConnection,
19
+ } from '@opentunnel/client'
20
+
21
+ const execFileAsync = promisify(execFile)
22
+
23
+ const PORT_WAIT_MS = 30_000
24
+ const CHILD_KILL_GRACE_MS = 5_000
25
+
26
+ // Only lines with host:port, so "Port 5173 is in use" does not match.
27
+ const LOCAL_PORT_PATTERN = /(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::\]):(\d{1,5})/i
28
+ const IGNORED_LINE_PATTERNS = [/\bdebugger (?:listening|attached)\b/i, /\bdevtools listening\b/i]
29
+
30
+ export function detectPortFromText(text: string): number | null {
31
+ // Vite colors the port separately, so strip ANSI before matching.
32
+ for (const line of stripVTControlCharacters(text).split('\n')) {
33
+ if (IGNORED_LINE_PATTERNS.some((pattern) => pattern.test(line))) continue
34
+ const port = Number(line.match(LOCAL_PORT_PATTERN)?.[1])
35
+ if (port >= 1 && port <= 65_535) return port
36
+ }
37
+ return null
38
+ }
39
+
40
+ // Traforo accepted any tunnel ID, so map it to a DNS label instead of failing: `My_App` becomes `my-app`.
41
+ function toRouteLabel(raw: string): string {
42
+ const label = raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63).replace(/-+$/, '')
43
+ return isValidRouteName(label) ? label : ''
44
+ }
45
+
46
+ // Explicit name, then the tuistory session ID (stable URL across restarts of one session), then random.
47
+ export function routeName({ name, tuistorySessionId }: { name?: string; tuistorySessionId?: string }): string | Error {
48
+ if (name) return toRouteLabel(name) || new Error(`Invalid tunnel ID ${JSON.stringify(name)}: use letters, digits and dashes`)
49
+ return toRouteLabel(tuistorySessionId ?? '') || crypto.randomBytes(8).toString('hex')
50
+ }
51
+
52
+ // Parses `netstat -ano` output on Windows: `TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 1234`.
53
+ export function parseNetstatListeningPids({ stdout, port }: { stdout: string; port: number }): number[] {
54
+ const pids = new Set<number>()
55
+ for (const line of stdout.split('\n')) {
56
+ const [protocol, local, , state, pid] = line.trim().split(/\s+/)
57
+ if (protocol !== 'TCP' || state !== 'LISTENING' || !local?.endsWith(`:${port}`)) continue
58
+ const parsed = Number(pid)
59
+ if (Number.isInteger(parsed) && parsed > 0) pids.add(parsed)
60
+ }
61
+ return [...pids]
62
+ }
63
+
64
+ function isPortInUse({ port, host }: { port: number; host: string }): Promise<boolean> {
65
+ return new Promise((resolve) => {
66
+ const socket = net.connect({ port, host })
67
+ socket.once('connect', () => { socket.destroy(); resolve(true) })
68
+ socket.once('error', () => { socket.destroy(); resolve(false) })
69
+ })
70
+ }
71
+
72
+ async function waitForPort({ port, host, signal }: { port: number; host: string; signal: AbortSignal }): Promise<void | Error> {
73
+ while (!(await isPortInUse({ port, host }))) {
74
+ if (signal.aborted) return new Error(`Timed out waiting for ${host}:${port} to accept connections`)
75
+ await sleep(300)
76
+ }
77
+ }
78
+
79
+ async function listeningPids(port: number): Promise<number[]> {
80
+ if (process.platform === 'win32') {
81
+ const { stdout } = await execFileAsync('netstat', ['-ano'], { timeout: 5_000 }).catch(() => ({ stdout: '' }))
82
+ return parseNetstatListeningPids({ stdout, port })
83
+ }
84
+ const { stdout } = await execFileAsync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { timeout: 5_000 }).catch(() => ({ stdout: '' }))
85
+ return stdout.split('\n').map(Number).filter((pid) => Number.isInteger(pid) && pid > 0)
86
+ }
87
+
88
+ async function killProcessOnPort({ port, host }: { port: number; host: string }): Promise<void | Error> {
89
+ for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
90
+ for (const pid of await listeningPids(port)) {
91
+ try { process.kill(pid, signal) } catch {}
92
+ }
93
+ for (let waited = 0; waited < 3_000; waited += 200) {
94
+ if (!(await isPortInUse({ port, host }))) return
95
+ await sleep(200)
96
+ }
97
+ }
98
+ return new Error(`Port ${port} is still in use after --kill`)
99
+ }
100
+
101
+ function detectPortFromOutput({ child, signal }: { child: ChildProcess; signal: AbortSignal }): Promise<number | Error> {
102
+ return new Promise((resolve) => {
103
+ let buffer = ''
104
+ const finish = (result: number | Error) => {
105
+ child.stdout?.off('data', onData)
106
+ child.stderr?.off('data', onData)
107
+ child.off('exit', onExit)
108
+ signal.removeEventListener('abort', onAbort)
109
+ resolve(result)
110
+ }
111
+ const onData = (chunk: Buffer) => {
112
+ buffer = (buffer + chunk.toString()).slice(-8_000)
113
+ const port = detectPortFromText(buffer)
114
+ if (port) finish(port)
115
+ }
116
+ const onExit = (code: number | null) => finish(new Error(`Command exited with code ${code} before printing a localhost URL. Pass --port`))
117
+ const onAbort = () => finish(new Error(`No localhost URL in the command output after ${PORT_WAIT_MS / 1000}s. Pass --port`))
118
+ child.stdout?.on('data', onData)
119
+ child.stderr?.on('data', onData)
120
+ child.once('exit', onExit)
121
+ signal.addEventListener('abort', onAbort, { once: true })
122
+ })
123
+ }
124
+
125
+ function killChild(child: ChildProcess | undefined, signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {
126
+ if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
127
+ return new Promise((resolve) => {
128
+ const timer = setTimeout(() => { child.kill('SIGKILL'); resolve() }, CHILD_KILL_GRACE_MS)
129
+ child.once('exit', () => { clearTimeout(timer); resolve() })
130
+ child.kill(signal)
131
+ })
132
+ }
133
+
134
+ const log = (line: string) => process.stderr.write(`${line}\n`)
135
+
136
+ function logTunnelEvent(event: OpenTunnelClientEvent) {
137
+ if (event.type === 'disconnected') log(`Tunnel disconnected: ${event.reason}`)
138
+ if (event.type === 'connected') log('Tunnel connected')
139
+ }
140
+
141
+ function forwardedHeaders({ headers, target }: { headers: http.IncomingHttpHeaders; target: string }): http.OutgoingHttpHeaders {
142
+ return {
143
+ ...headers,
144
+ host: target,
145
+ 'x-forwarded-host': headers['x-forwarded-host'] ?? headers.host,
146
+ 'x-forwarded-proto': headers['x-forwarded-proto'] ?? 'https',
147
+ }
148
+ }
149
+
150
+ function rawResponseHead(response: http.IncomingMessage): string {
151
+ let head = `HTTP/1.1 ${response.statusCode} ${response.statusMessage}\r\n`
152
+ for (let i = 0; i < response.rawHeaders.length; i += 2) head += `${response.rawHeaders[i]}: ${response.rawHeaders[i + 1]}\r\n`
153
+ return `${head}\r\n`
154
+ }
155
+
156
+ // opentunnel forwards the raw bytes, so the app would see the public Host header. Vite and other dev
157
+ // servers reject unknown hosts (DNS rebinding protection) with 403. This loopback proxy sends
158
+ // Host: localhost:<port> and keeps the public host in X-Forwarded-Host, like traforo did.
159
+ export function startHostProxy({ host, port }: { host: string; port: number }): Promise<http.Server | Error> {
160
+ const target = `${host}:${port}`
161
+ const server = http.createServer((req, res) => {
162
+ const upstream = http.request({ host, port, method: req.method, path: req.url, headers: forwardedHeaders({ headers: req.headers, target }) }, (response) => {
163
+ res.writeHead(response.statusCode ?? 502, response.statusMessage, response.rawHeaders)
164
+ response.pipe(res)
165
+ })
166
+ upstream.on('error', () => {
167
+ if (!res.headersSent) res.writeHead(502)
168
+ res.end()
169
+ })
170
+ req.pipe(upstream)
171
+ })
172
+
173
+ // WebSocket upgrades (Vite HMR): replay the handshake upstream, then pipe both sockets.
174
+ server.on('upgrade', (req, socket, head) => {
175
+ const upstream = http.request({ host, port, method: req.method, path: req.url, headers: forwardedHeaders({ headers: req.headers, target }) })
176
+ upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
177
+ socket.write(rawResponseHead(response))
178
+ if (upstreamHead.length > 0) socket.write(upstreamHead)
179
+ if (head.length > 0) upstreamSocket.write(head)
180
+ upstreamSocket.on('error', () => socket.destroy())
181
+ socket.on('error', () => upstreamSocket.destroy())
182
+ upstreamSocket.pipe(socket).pipe(upstreamSocket)
183
+ })
184
+ upstream.on('response', (response) => {
185
+ socket.write(rawResponseHead(response))
186
+ response.pipe(socket)
187
+ })
188
+ upstream.on('error', () => socket.destroy())
189
+ socket.on('error', () => upstream.destroy())
190
+ upstream.end()
191
+ })
192
+
193
+ return new Promise((resolve) => {
194
+ server.once('error', (cause) => resolve(new Error('Could not start the tunnel proxy', { cause })))
195
+ server.listen(0, '127.0.0.1', () => resolve(server))
196
+ })
197
+ }
198
+
199
+ export async function runTunnel(options: {
200
+ port?: number
201
+ command?: string[]
202
+ name?: string
203
+ host?: string
204
+ kill?: boolean
205
+ }): Promise<void | Error> {
206
+ const host = options.host ?? 'localhost'
207
+ const route = routeName({ name: options.name, tuistorySessionId: process.env['TUISTORY_SESSION_ID'] })
208
+ if (route instanceof Error) return route
209
+ if (options.kill && !options.port) return new Error('--kill needs --port')
210
+
211
+ if (options.port && options.kill) {
212
+ const killed = await killProcessOnPort({ port: options.port, host })
213
+ if (killed instanceof Error) return killed
214
+ } else if (options.port && options.command && (await isPortInUse({ port: options.port, host }))) {
215
+ return new Error(`Port ${options.port} is already in use. Pass --kill to stop the process on it first`)
216
+ }
217
+
218
+ const client = createOpenTunnel()
219
+ let waitLogged = false
220
+ const identity = await client.tunnel
221
+ .ensure({
222
+ onProgress: (stage) => {
223
+ if (waitLogged || stage === 'checking-certificate' || stage === 'ready') return
224
+ waitLogged = true
225
+ log('Getting a certificate for the opentunnel.xyz tunnel of this machine (up to a minute)...')
226
+ },
227
+ })
228
+ .catch((cause) => new Error('Could not create the opentunnel tunnel', { cause }))
229
+ if (identity instanceof Error) return identity
230
+ const url = publicUrl(route, identity.hostname)
231
+
232
+ let child: ChildProcess | undefined
233
+ let connection: OpenTunnelConnection | undefined
234
+ // Set before we kill the child ourselves, so its exit does not end the process first.
235
+ let stopping = false
236
+ const shutdown = async (signal: NodeJS.Signals) => {
237
+ if (stopping) return
238
+ stopping = true
239
+ log('Shutting down...')
240
+ await connection?.close().catch(() => undefined)
241
+ await killChild(child, signal)
242
+ process.exit(0)
243
+ }
244
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.once(signal, () => void shutdown(signal))
245
+
246
+ if (options.command) {
247
+ const [cmd, ...args] = options.command
248
+ child = spawn(cmd!, args, {
249
+ stdio: ['inherit', 'pipe', 'pipe'],
250
+ env: {
251
+ ...process.env,
252
+ ...(options.port && { PORT: String(options.port) }),
253
+ KIMAKI_TUNNEL_URL: url,
254
+ // Old name from traforo, kept so existing app configs still work.
255
+ TRAFORO_URL: url,
256
+ // wrangler dev passes parent env vars as worker bindings, so workers can read KIMAKI_TUNNEL_URL.
257
+ CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true',
258
+ FORCE_COLOR: '1',
259
+ VITE_CLS: 'false',
260
+ NEXT_TELEMETRY_DISABLED: '1',
261
+ },
262
+ })
263
+ child.stdout?.pipe(process.stdout)
264
+ child.stderr?.pipe(process.stderr)
265
+ child.on('error', (error) => { log(`Failed to start command: ${error.message}`); process.exit(1) })
266
+ child.on('exit', (code, signal) => {
267
+ if (stopping) return
268
+ log(`Command exited with ${signal ?? `code ${code}`}`)
269
+ process.exit(code ?? 1)
270
+ })
271
+ }
272
+
273
+ const fail = async (error: Error) => { stopping = true; await killChild(child); return error }
274
+
275
+ let port = options.port
276
+ const portSignal = AbortSignal.timeout(PORT_WAIT_MS)
277
+ if (!port && child) {
278
+ const detected = await detectPortFromOutput({ child, signal: portSignal })
279
+ if (detected instanceof Error) return fail(detected)
280
+ port = detected
281
+ }
282
+ if (!port) return fail(new Error('Could not find the local port. Pass --port'))
283
+ const ready = await waitForPort({ port, host, signal: portSignal })
284
+ if (ready instanceof Error) return fail(ready)
285
+ const proxy = await startHostProxy({ host, port })
286
+ if (proxy instanceof Error) return fail(proxy)
287
+ const proxyAddress = proxy.address()
288
+ if (!proxyAddress || typeof proxyAddress === 'string') return fail(new Error('Tunnel proxy has no TCP address'))
289
+
290
+ const attached = await client.tunnel
291
+ .connect({ routes: { [route]: `127.0.0.1:${proxyAddress.port}` }, onRouteConflict: 'fail', onEvent: logTunnelEvent })
292
+ .catch((cause) => {
293
+ if (cause instanceof OpenTunnelClientError && cause.code === 'route_conflict') {
294
+ return new Error(`Tunnel ID ${route} is already used by another process. Pass a different -t`, { cause })
295
+ }
296
+ return new Error('Could not connect the tunnel', { cause })
297
+ })
298
+ if (attached instanceof Error) return fail(attached)
299
+ connection = attached
300
+
301
+ process.stdout.write(
302
+ `\nLocal: http://${host}:${port}\nTunnel: ${url}\n` +
303
+ `Agents: validate with the Local URL (playwriter, curl), it is much faster. The Tunnel URL is for the user.\n\n`,
304
+ )
305
+
306
+ const closed = await attached.closed.then(() => undefined, (cause) => new Error('Tunnel stopped', { cause }))
307
+ return fail(closed ?? new Error('Tunnel stopped'))
308
+ }