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.
- package/dist/bot.d.ts +3 -5
- package/dist/bot.js +3 -9
- package/dist/bot.js.map +1 -1
- package/dist/builtin-skills.d.ts +3 -0
- package/dist/builtin-skills.js +29 -0
- package/dist/builtin-skills.js.map +1 -1
- package/dist/builtin-skills.test.js +73 -1
- package/dist/builtin-skills.test.js.map +1 -1
- package/dist/cli/bot.d.ts +1 -1
- package/dist/cli/bot.js +8 -4
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli-lock.e2e.test.js +1 -1
- package/dist/event-loop.d.ts +1 -2
- package/dist/event-loop.js +2 -2
- package/dist/event-loop.js.map +1 -1
- package/dist/main.d.ts +2 -1
- package/dist/main.js +2 -2
- package/dist/main.js.map +1 -1
- package/dist/markdown/markdown.test.js +10 -0
- package/dist/markdown/markdown.test.js.map +1 -1
- package/dist/markdown/render-markdown.js +3 -1
- package/dist/markdown/render-markdown.js.map +1 -1
- package/dist/plugin/index.js +17 -44
- package/dist/plugin/index.js.map +1 -1
- package/dist/session-events.js +9 -5
- package/dist/session-events.js.map +1 -1
- package/dist/sessions.js +4 -3
- package/dist/sessions.js.map +1 -1
- package/dist/system-prompt.d.ts +3 -15
- package/dist/system-prompt.js +52 -77
- package/dist/system-prompt.js.map +1 -1
- package/dist/test/harness.d.ts +1 -2
- package/dist/test/harness.js +1 -2
- package/dist/test/harness.js.map +1 -1
- package/dist/test/replay.js +0 -1
- package/dist/test/replay.js.map +1 -1
- package/dist/thread-reducer.d.ts +0 -3
- package/dist/thread-reducer.fixtures.test.js +2 -2
- package/dist/thread-reducer.fixtures.test.js.map +1 -1
- package/dist/thread-reducer.js +4 -32
- package/dist/thread-reducer.js.map +1 -1
- package/dist/thread-reducer.test.js +0 -31
- package/dist/thread-reducer.test.js.map +1 -1
- package/dist/tunnel.d.ts +9 -0
- package/dist/tunnel.js +117 -28
- package/dist/tunnel.js.map +1 -1
- package/dist/tunnel.test.js +81 -5
- package/dist/tunnel.test.js.map +1 -1
- package/package.json +4 -4
- package/skills/kimaki-tunnel/SKILL.md +3 -2
- package/src/bot.ts +7 -14
- package/src/builtin-skills.test.ts +74 -1
- package/src/builtin-skills.ts +33 -0
- package/src/cli/bot.ts +7 -5
- package/src/cli-lock.e2e.test.ts +1 -1
- package/src/event-loop.ts +1 -4
- package/src/main.ts +5 -4
- package/src/markdown/markdown.test.ts +11 -0
- package/src/markdown/render-markdown.ts +3 -1
- package/src/plugin/index.ts +18 -44
- package/src/session-events.ts +8 -4
- package/src/sessions.ts +3 -3
- package/src/system-prompt.ts +56 -80
- package/src/test/harness.ts +0 -3
- package/src/test/replay.ts +0 -1
- package/src/thread-reducer.fixtures.test.ts +2 -2
- package/src/thread-reducer.test.ts +0 -31
- package/src/thread-reducer.ts +4 -36
- package/src/tunnel.test.ts +81 -5
- package/src/tunnel.ts +124 -25
- 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 {
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 =
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
})
|