kimaki 1.3.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 (71) hide show
  1. package/dist/bot.d.ts +3 -5
  2. package/dist/bot.js +3 -9
  3. package/dist/bot.js.map +1 -1
  4. package/dist/builtin-skills.d.ts +3 -0
  5. package/dist/builtin-skills.js +29 -0
  6. package/dist/builtin-skills.js.map +1 -1
  7. package/dist/builtin-skills.test.js +73 -1
  8. package/dist/builtin-skills.test.js.map +1 -1
  9. package/dist/cli/bot.d.ts +1 -1
  10. package/dist/cli/bot.js +8 -4
  11. package/dist/cli/bot.js.map +1 -1
  12. package/dist/cli-lock.e2e.test.js +1 -1
  13. package/dist/event-loop.d.ts +1 -2
  14. package/dist/event-loop.js +2 -2
  15. package/dist/event-loop.js.map +1 -1
  16. package/dist/main.d.ts +2 -1
  17. package/dist/main.js +2 -2
  18. package/dist/main.js.map +1 -1
  19. package/dist/markdown/markdown.test.js +10 -0
  20. package/dist/markdown/markdown.test.js.map +1 -1
  21. package/dist/markdown/render-markdown.js +3 -1
  22. package/dist/markdown/render-markdown.js.map +1 -1
  23. package/dist/plugin/index.js +17 -44
  24. package/dist/plugin/index.js.map +1 -1
  25. package/dist/session-events.js +9 -5
  26. package/dist/session-events.js.map +1 -1
  27. package/dist/sessions.js +4 -3
  28. package/dist/sessions.js.map +1 -1
  29. package/dist/system-prompt.d.ts +3 -15
  30. package/dist/system-prompt.js +52 -77
  31. package/dist/system-prompt.js.map +1 -1
  32. package/dist/test/harness.d.ts +1 -2
  33. package/dist/test/harness.js +1 -2
  34. package/dist/test/harness.js.map +1 -1
  35. package/dist/test/replay.js +0 -1
  36. package/dist/test/replay.js.map +1 -1
  37. package/dist/thread-reducer.d.ts +0 -3
  38. package/dist/thread-reducer.fixtures.test.js +2 -2
  39. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  40. package/dist/thread-reducer.js +4 -32
  41. package/dist/thread-reducer.js.map +1 -1
  42. package/dist/thread-reducer.test.js +0 -31
  43. package/dist/thread-reducer.test.js.map +1 -1
  44. package/dist/tunnel.d.ts +9 -0
  45. package/dist/tunnel.js +117 -28
  46. package/dist/tunnel.js.map +1 -1
  47. package/dist/tunnel.test.js +81 -5
  48. package/dist/tunnel.test.js.map +1 -1
  49. package/package.json +4 -4
  50. package/skills/kimaki-tunnel/SKILL.md +3 -2
  51. package/src/bot.ts +7 -14
  52. package/src/builtin-skills.test.ts +74 -1
  53. package/src/builtin-skills.ts +33 -0
  54. package/src/cli/bot.ts +7 -5
  55. package/src/cli-lock.e2e.test.ts +1 -1
  56. package/src/event-loop.ts +1 -4
  57. package/src/main.ts +5 -4
  58. package/src/markdown/markdown.test.ts +11 -0
  59. package/src/markdown/render-markdown.ts +3 -1
  60. package/src/plugin/index.ts +18 -44
  61. package/src/session-events.ts +8 -4
  62. package/src/sessions.ts +3 -3
  63. package/src/system-prompt.ts +56 -80
  64. package/src/test/harness.ts +0 -3
  65. package/src/test/replay.ts +0 -1
  66. package/src/thread-reducer.fixtures.test.ts +2 -2
  67. package/src/thread-reducer.test.ts +0 -31
  68. package/src/thread-reducer.ts +4 -36
  69. package/src/tunnel.test.ts +81 -5
  70. package/src/tunnel.ts +124 -25
  71. package/src/message-tool.e2e.test.ts +0 -45
package/src/tunnel.ts CHANGED
@@ -5,16 +5,22 @@
5
5
 
6
6
  import crypto from 'node:crypto'
7
7
  import { execFile, spawn, type ChildProcess } from 'node:child_process'
8
+ import http from 'node:http'
8
9
  import net from 'node:net'
9
10
  import { setTimeout as sleep } from 'node:timers/promises'
10
11
  import { promisify, stripVTControlCharacters } from 'node:util'
11
- import { create as createOpenTunnel, type OpenTunnelConnection } from '@opentunnel/client'
12
+ import {
13
+ create as createOpenTunnel,
14
+ isValidRouteName,
15
+ OpenTunnelClientError,
16
+ publicUrl,
17
+ type OpenTunnelClientEvent,
18
+ type OpenTunnelConnection,
19
+ } from '@opentunnel/client'
12
20
 
13
21
  const execFileAsync = promisify(execFile)
14
22
 
15
- const ROUTE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
16
23
  const PORT_WAIT_MS = 30_000
17
- const ATTACH_TIMEOUT_MS = 60_000
18
24
  const CHILD_KILL_GRACE_MS = 5_000
19
25
 
20
26
  // Only lines with host:port, so "Port 5173 is in use" does not match.
@@ -31,12 +37,28 @@ export function detectPortFromText(text: string): number | null {
31
37
  return null
32
38
  }
33
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
+
34
46
  // Explicit name, then the tuistory session ID (stable URL across restarts of one session), then random.
35
47
  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')
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]
40
62
  }
41
63
 
42
64
  function isPortInUse({ port, host }: { port: number; host: string }): Promise<boolean> {
@@ -55,6 +77,10 @@ async function waitForPort({ port, host, signal }: { port: number; host: string;
55
77
  }
56
78
 
57
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
+ }
58
84
  const { stdout } = await execFileAsync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { timeout: 5_000 }).catch(() => ({ stdout: '' }))
59
85
  return stdout.split('\n').map(Number).filter((pid) => Number.isInteger(pid) && pid > 0)
60
86
  }
@@ -107,6 +133,69 @@ function killChild(child: ChildProcess | undefined, signal: NodeJS.Signals = 'SI
107
133
 
108
134
  const log = (line: string) => process.stderr.write(`${line}\n`)
109
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
+
110
199
  export async function runTunnel(options: {
111
200
  port?: number
112
201
  command?: string[]
@@ -127,11 +216,18 @@ export async function runTunnel(options: {
127
216
  }
128
217
 
129
218
  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 }))
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 }))
133
229
  if (identity instanceof Error) return identity
134
- const url = `https://${route}.${identity.hostname}`
230
+ const url = publicUrl(route, identity.hostname)
135
231
 
136
232
  let child: ChildProcess | undefined
137
233
  let connection: OpenTunnelConnection | undefined
@@ -186,24 +282,27 @@ export async function runTunnel(options: {
186
282
  if (!port) return fail(new Error('Could not find the local port. Pass --port'))
187
283
  const ready = await waitForPort({ port, host, signal: portSignal })
188
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'))
189
289
 
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 }))
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
+ })
194
298
  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
299
  connection = attached
197
300
 
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
- })()
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
+ )
206
305
 
207
- const closed = await attached.closed.then(() => undefined, (cause: unknown) => new Error('Tunnel stopped', { cause }))
306
+ const closed = await attached.closed.then(() => undefined, (cause) => new Error('Tunnel stopped', { cause }))
208
307
  return fail(closed ?? new Error('Tunnel stopped'))
209
308
  }
@@ -1,45 +0,0 @@
1
- // `kimaki --message-tool`: the plugin gives root sessions a send_message tool
2
- // and the thread shows only its text. The model's plain text stays hidden.
3
-
4
- import fs from 'node:fs'
5
- import { afterAll, beforeAll, expect, test } from 'vitest'
6
-
7
- import type { BotHandle } from './main.ts'
8
- import { scriptedTurn, seedProjectChannel, startOpencodeTestServer, startTestBot, startTwin, tempDataDir, TEST_USER_ID, waitForFooter, warmUp, type OpencodeTestServer, type TestTwin } from './test/harness.ts'
9
-
10
- const dataDir = tempDataDir()
11
- let server: OpencodeTestServer
12
- let twin: TestTwin
13
- let bot: BotHandle
14
-
15
- beforeAll(async () => {
16
- const matchers = scriptedTurn({
17
- marker: 'message-tool-marker',
18
- steps: [{ id: 'message-call', tool: 'send_message', input: { text: 'Hello from **send_message**' } }],
19
- finalText: 'hidden scratchpad text',
20
- })
21
- ;[server, twin] = await Promise.all([startOpencodeTestServer({ matchers }), startTwin()])
22
- await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
23
- bot = await startTestBot({ dataDir, twin, server, messageTool: true })
24
- await warmUp({ server })
25
- }, 60_000)
26
-
27
- afterAll(async () => {
28
- await bot?.stop()
29
- await Promise.all([server?.stop(), twin?.stop()])
30
- fs.rmSync(dataDir, { recursive: true, force: true })
31
- })
32
-
33
- test('send_message text shows in Discord, plain text of the same turn does not', async () => {
34
- await twin.discord.channel(twin.channelId).user(TEST_USER_ID).sendMessage({ content: 'message-tool-marker' })
35
- const thread = await twin.discord.channel(twin.channelId).waitForThread({ timeout: 8_000 })
36
- await waitForFooter({ discord: twin.discord, threadId: thread.id })
37
- expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
38
- "--- from: user (tommy)
39
- message-tool-marker
40
- --- from: assistant (TestBot)
41
- -# *using deterministic-provider/deterministic-v2 ⋅ build*
42
- Hello from **send_message**
43
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
44
- `)
45
- })