threadwire 0.1.11 → 0.1.13

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.
@@ -21,12 +21,20 @@ const UPSTREAM_URL = "https://api.kimi.com/coding/v1/chat/completions"
21
21
  /** @typedef {ReturnType<typeof createKimiGrantStore>} KimiGrantStore */
22
22
  /** @typedef {Awaited<ReturnType<typeof openKimiOAuthStore>>} KimiOAuthStore */
23
23
 
24
- /** @param {{environment?: NodeJS.ProcessEnv, fetchImplementation?: typeof fetch, oauthFetchImplementation?: typeof fetch}} [options] */
24
+ /** @param {{environment?: NodeJS.ProcessEnv, fetchImplementation?: typeof fetch, oauthFetchImplementation?: typeof fetch, onWorkerRequestAuthorized?: (token: string) => void}} [options] */
25
25
  export async function startKimiModelBroker(options = {}) {
26
26
  const environment = options.environment ?? process.env
27
27
  const host = safeHost(environment.THREADWIRE_KIMI_MODEL_BROKER_HOST ?? "0.0.0.0")
28
28
  const port = portValue(environment.THREADWIRE_KIMI_MODEL_BROKER_PORT, 8791)
29
+ const workerHost = safeHost(environment.THREADWIRE_KIMI_MODEL_BROKER_WORKER_HOST ?? "0.0.0.0")
30
+ const workerPort = portValue(environment.THREADWIRE_KIMI_MODEL_BROKER_WORKER_PORT, 8792)
29
31
  const adminToken = authoritySetting(environment.THREADWIRE_KIMI_MODEL_BROKER_ADMIN_TOKEN, "THREADWIRE_KIMI_MODEL_BROKER_ADMIN_TOKEN")
32
+ // The published worker bind is an explicit deployment decision: Compose
33
+ // renders a non-task-reachable loopback sentinel only so an inactive Kimi
34
+ // profile loads, and passes the raw setting through. Startup requires a
35
+ // nonempty supported bind before either listener opens, so a missing or
36
+ // invalid bind exits instead of publishing on every interface.
37
+ const workerBind = bindSetting(environment.THREADWIRE_KIMI_MODEL_BROKER_WORKER_BIND, "THREADWIRE_KIMI_MODEL_BROKER_WORKER_BIND")
30
38
  const approvedModels = parseApprovedKimiModels(environment.THREADWIRE_ALLOWED_KIMI_MODELS)
31
39
  const oauth = await openKimiOAuthStore({
32
40
  file: safeSetting(environment.THREADWIRE_KIMI_OAUTH_FILE, "THREADWIRE_KIMI_OAUTH_FILE"),
@@ -34,27 +42,48 @@ export async function startKimiModelBroker(options = {}) {
34
42
  })
35
43
  const fetchImplementation = options.fetchImplementation ?? fetch
36
44
  const grants = createKimiGrantStore()
37
- /** @type {Map<string, import("node:http").Server>} */
38
- const listeners = new Map()
39
- /** @type {Map<string, {controller: AbortController, timer: NodeJS.Timeout, sockets: Set<import("node:net").Socket>}>} */
45
+ /** @type {Map<string, {controller: AbortController, timer: NodeJS.Timeout, grant: KimiGrant}>} */
40
46
  const runtimes = new Map()
41
47
  /** @type {Set<import("node:net").Socket>} */
42
48
  const controlSockets = new Set()
49
+ /** @type {Set<import("node:net").Socket>} */
50
+ const workerSockets = new Set()
43
51
  let stopping = false
44
52
 
45
53
  /** @param {string} token */
46
- const revokeGrant = async (token) => {
54
+ const revokeGrant = (token) => {
47
55
  grants.revoke(token)
48
56
  const runtime = runtimes.get(token)
49
57
  runtimes.delete(token)
50
- if (runtime !== undefined) {
51
- clearTimeout(runtime.timer)
52
- runtime.controller.abort(new Error("Kimi broker grant revoked"))
53
- for (const socket of runtime.sockets) socket.destroy()
54
- runtime.sockets.clear()
55
- }
56
- await closeListener(listeners, token)
58
+ if (runtime === undefined) return
59
+ clearTimeout(runtime.timer)
60
+ runtime.controller.abort(new Error("Kimi broker grant revoked"))
57
61
  }
62
+ /** @param {string} token @param {number} expiresAt */
63
+ const scheduleExpiry = (token, expiresAt) => {
64
+ const timer = setTimeout(() => revokeGrant(token), Math.max(1, expiresAt - Date.now()))
65
+ timer.unref()
66
+ return timer
67
+ }
68
+
69
+ const workerServer = createServer((request, response) => {
70
+ /** @type {{grants: KimiGrantStore, runtimes: Map<string, {controller: AbortController, timer: NodeJS.Timeout, grant: KimiGrant}>, oauth: KimiOAuthStore, fetchImplementation: typeof fetch, onWorkerRequestAuthorized?: (token: string) => void}} */
71
+ const dependencies = {grants, runtimes, oauth, fetchImplementation}
72
+ if (options.onWorkerRequestAuthorized !== undefined) dependencies.onWorkerRequestAuthorized = options.onWorkerRequestAuthorized
73
+ proxyWorkerRequest(request, response, dependencies).catch((error) => {
74
+ if (error instanceof Error && /^Kimi broker request denied (?:header: [a-z0-9-]+|body keys: (?:unsafe|[a-z0-9_,]+))$/u.test(error.message)) {
75
+ process.stderr.write(`threadwire-kimi-broker: ${error.message}\n`)
76
+ }
77
+ if (response.destroyed) return
78
+ const status = clientError(error) ? 403 : 502
79
+ sendJson(response, status, {error: status === 403 ? "request denied" : "Kimi model broker unavailable"})
80
+ })
81
+ })
82
+ workerServer.on("connection", (socket) => {
83
+ if (stopping) return socket.destroy()
84
+ workerSockets.add(socket)
85
+ socket.once("close", () => workerSockets.delete(socket))
86
+ })
58
87
 
59
88
  const server = createServer(async (request, response) => {
60
89
  try {
@@ -70,37 +99,23 @@ export async function startKimiModelBroker(options = {}) {
70
99
  if (request.method === "POST" && request.url === "/admin/grants") {
71
100
  const health = await oauth.health()
72
101
  if (!health.ready) throw new Error("Kimi OAuth login required")
73
- const body = await readJson(request)
74
- const selection = parseGrant(body, approvedModels)
75
- const token = grants.issue(selection.grant)
102
+ const grant = parseGrant(await readJson(request), approvedModels)
103
+ const token = grants.issue(grant)
76
104
  const controller = new AbortController()
77
- /** @type {Set<import("node:net").Socket>} */
78
- const sockets = new Set()
79
- const listener = createServer((workerRequest, workerResponse) => {
80
- proxyWorkerRequest(workerRequest, workerResponse, {
81
- token, grant: selection.grant, grants, oauth, fetchImplementation, signal: controller.signal
82
- }).catch((error) => {
83
- if (workerResponse.destroyed) return
84
- const status = clientError(error) ? 403 : 502
85
- sendJson(workerResponse, status, {error: status === 403 ? "request denied" : "Kimi model broker unavailable"})
86
- })
87
- })
88
- listener.on("connection", (socket) => {
89
- if (controller.signal.aborted) return socket.destroy()
90
- sockets.add(socket)
91
- socket.once("close", () => sockets.delete(socket))
92
- })
93
- try { await listen(listener, 0, selection.brokerAddress) } catch (error) {
94
- grants.revoke(token)
95
- throw error
96
- }
97
- listeners.set(token, listener)
98
- const timer = setTimeout(() => { void revokeGrant(token) }, selection.grant.ttlMs)
99
- timer.unref()
100
- runtimes.set(token, {controller, timer, sockets})
101
- const address = listener.address()
102
- if (address === null || typeof address === "string") throw new Error("Kimi broker listener unavailable")
103
- sendJson(response, 201, {token, port: address.port})
105
+ runtimes.set(token, {controller, timer: scheduleExpiry(token, Date.now() + grant.ttlMs), grant})
106
+ sendJson(response, 201, {token})
107
+ return
108
+ }
109
+ if (request.method === "POST" && request.url?.startsWith("/admin/grants/") && request.url.endsWith("/renew")) {
110
+ const token = decodeURIComponent(request.url.slice("/admin/grants/".length, -"/renew".length))
111
+ const body = await readJson(request)
112
+ if (!record(body) || typeof body.ttlMs !== "number") throw new Error("Invalid Kimi broker grant")
113
+ const renewed = grants.renew(token, body.ttlMs)
114
+ const runtime = runtimes.get(token)
115
+ if (runtime === undefined) throw new Error("Kimi broker grant unknown token")
116
+ clearTimeout(runtime.timer)
117
+ runtime.timer = scheduleExpiry(token, renewed.expiresAt)
118
+ sendJson(response, 200, {ok: true})
104
119
  return
105
120
  }
106
121
  if (request.method === "POST" && request.url?.startsWith("/admin/grants/") && request.url.endsWith("/activate")) {
@@ -111,7 +126,7 @@ export async function startKimiModelBroker(options = {}) {
111
126
  }
112
127
  if (request.method === "DELETE" && request.url?.startsWith("/admin/grants/")) {
113
128
  const token = decodeURIComponent(request.url.slice("/admin/grants/".length))
114
- await revokeGrant(token)
129
+ revokeGrant(token)
115
130
  response.writeHead(204)
116
131
  response.end()
117
132
  return
@@ -123,65 +138,91 @@ export async function startKimiModelBroker(options = {}) {
123
138
  }
124
139
  })
125
140
  server.on("connection", (socket) => {
141
+ if (stopping) return socket.destroy()
126
142
  controlSockets.add(socket)
127
143
  socket.once("close", () => controlSockets.delete(socket))
128
144
  })
129
- await listen(server, port, host)
145
+ await listen(workerServer, workerPort, workerHost)
146
+ try { await listen(server, port, host) } catch (error) {
147
+ await boundedClose(workerServer)
148
+ throw error
149
+ }
130
150
  return {
131
151
  server,
132
- activeListeners: () => listeners.size,
152
+ workerServer,
153
+ activeListeners: () => runtimes.size,
133
154
  close: async () => {
134
155
  if (stopping) return
135
156
  stopping = true
157
+ for (const token of [...runtimes.keys()]) revokeGrant(token)
136
158
  for (const socket of controlSockets) socket.destroy()
137
- await Promise.all([...listeners.keys()].map(revokeGrant))
138
- await boundedClose(server)
159
+ for (const socket of workerSockets) socket.destroy()
160
+ await Promise.all([boundedClose(server), boundedClose(workerServer)])
139
161
  },
140
- config: {host, port, models: [...approvedModels.keys()]}
162
+ config: {host, port, workerHost, workerPort, workerBind, models: [...approvedModels.keys()]}
141
163
  }
142
164
  }
143
165
 
144
166
  /**
145
167
  * @param {import("node:http").IncomingMessage} request
146
168
  * @param {import("node:http").ServerResponse} response
147
- * @param {{token: string, grant: KimiGrant, grants: KimiGrantStore, oauth: KimiOAuthStore, fetchImplementation: typeof fetch, signal: AbortSignal}} options
169
+ * @param {{grants: KimiGrantStore, runtimes: Map<string, {controller: AbortController, timer: NodeJS.Timeout, grant: KimiGrant}>, oauth: KimiOAuthStore, fetchImplementation: typeof fetch, onWorkerRequestAuthorized?: (token: string) => void}} options
148
170
  */
149
171
  async function proxyWorkerRequest(request, response, options) {
150
- throwIfAborted(options.signal)
151
172
  const policyHeaders = {...request.headers}
152
173
  delete policyHeaders.host
153
174
  delete policyHeaders.connection
154
175
  delete policyHeaders["transfer-encoding"]
155
176
  validateKimiBrokerRequest({method: request.method ?? "", path: request.url ?? "", headers: policyHeaders})
156
177
  const token = kimiBearerToken(request.headers.authorization)
157
- if (token !== options.token) throw new Error("Kimi broker request denied")
178
+ const runtime = options.runtimes.get(token)
179
+ if (runtime === undefined) throw new Error("Kimi broker grant unknown token")
180
+ const signal = runtime.controller.signal
181
+ throwIfAborted(signal)
158
182
  const context = {
159
- provider: "kimi", runId: options.grant.runId,
160
- networkId: options.grant.networkId, taskId: options.grant.taskId,
161
- sessionId: options.grant.sessionId, modelAlias: options.grant.modelAlias
183
+ provider: "kimi", runId: runtime.grant.runId,
184
+ networkId: runtime.grant.networkId, taskId: runtime.grant.taskId,
185
+ sessionId: runtime.grant.sessionId, modelAlias: runtime.grant.modelAlias
162
186
  }
163
187
  const grant = options.grants.authorize(token, context)
164
- const body = await readBody(request, options.signal)
165
- options.grants.authorize(token, context)
166
- validateKimiChatBody(body, grant.model)
167
- const accessToken = await options.oauth.getAccessToken({signal: options.signal})
168
- throwIfAborted(options.signal)
169
- const upstream = await abortable(options.fetchImplementation(UPSTREAM_URL, {
170
- method: "POST",
171
- redirect: "error",
172
- headers: {
173
- authorization: `Bearer ${accessToken}`,
174
- "content-type": "application/json",
175
- accept: "text/event-stream",
176
- "user-agent": "threadwire-kimi-model-broker/1"
177
- },
178
- body,
179
- signal: options.signal
180
- }), options.signal)
181
- throwIfAborted(options.signal)
182
- const contentType = safeContentType(upstream.headers.get("content-type"))
183
- response.writeHead(upstream.status, {"content-type": contentType, "cache-control": "no-store"})
184
- await streamResponse(upstream, response, options.signal)
188
+ // Couple this authenticated in-flight request to the grant abort signal:
189
+ // revocation or expiry destroys this request's own socket, waking a stalled
190
+ // body read so the upload terminates and releases its request/socket
191
+ // resources. Only this request's socket is touched; unrelated grants and
192
+ // broker shutdown semantics are unaffected.
193
+ const wake = () => request.socket.destroy()
194
+ signal.addEventListener("abort", wake, {once: true})
195
+ // Never miss an abort that raced the hook installation.
196
+ if (signal.aborted) {
197
+ signal.removeEventListener("abort", wake)
198
+ wake()
199
+ throwIfAborted(signal)
200
+ }
201
+ // Optional lifecycle seam: fires only after the grant is authorized and the
202
+ // abort hook is installed, i.e. once authenticated body processing begins.
203
+ options.onWorkerRequestAuthorized?.(token)
204
+ try {
205
+ const body = await readBody(request, signal)
206
+ options.grants.authorize(token, context)
207
+ validateKimiChatBody(body, grant.model)
208
+ const accessToken = await options.oauth.getAccessToken({signal})
209
+ throwIfAborted(signal)
210
+ const upstream = await abortable(options.fetchImplementation(UPSTREAM_URL, {
211
+ method: "POST", redirect: "error",
212
+ headers: {
213
+ authorization: `Bearer ${accessToken}`,
214
+ "content-type": "application/json", accept: "text/event-stream",
215
+ "user-agent": "threadwire-kimi-model-broker/1"
216
+ },
217
+ body, signal
218
+ }), signal)
219
+ throwIfAborted(signal)
220
+ const contentType = safeContentType(upstream.headers.get("content-type"))
221
+ response.writeHead(upstream.status, {"content-type": contentType, "cache-control": "no-store"})
222
+ await streamResponse(upstream, response, signal)
223
+ } finally {
224
+ signal.removeEventListener("abort", wake)
225
+ }
185
226
  }
186
227
 
187
228
  /** @param {Response} upstream @param {import("node:http").ServerResponse} response @param {AbortSignal} signal */
@@ -206,24 +247,20 @@ async function streamResponse(upstream, response, signal) {
206
247
  } finally { reader.releaseLock() }
207
248
  }
208
249
 
209
- /** @param {unknown} value @param {Map<string, ApprovedKimiModel>} approvedModels @returns {{brokerAddress: string, grant: KimiGrant}} */
250
+ /** @param {unknown} value @param {Map<string, ApprovedKimiModel>} approvedModels @returns {KimiGrant} */
210
251
  function parseGrant(value, approvedModels) {
211
- if (!record(value) || !exactKeys(value, ["brokerAddress", "modelAlias", "networkId", "provider", "runId", "sessionId", "taskId", "ttlMs"])
252
+ if (!record(value) || !exactKeys(value, ["modelAlias", "networkId", "provider", "runId", "sessionId", "taskId", "ttlMs"])
212
253
  || value.provider !== "kimi" || typeof value.runId !== "string" || typeof value.networkId !== "string"
213
254
  || typeof value.taskId !== "string" || typeof value.sessionId !== "string"
214
- || typeof value.modelAlias !== "string" || typeof value.brokerAddress !== "string" || typeof value.ttlMs !== "number") {
255
+ || typeof value.modelAlias !== "string" || typeof value.ttlMs !== "number") {
215
256
  throw new Error("Invalid Kimi broker grant")
216
257
  }
217
- if (!/^(?:\d{1,3}\.){3}\d{1,3}$/u.test(value.brokerAddress)) throw new Error("Invalid Kimi broker grant")
218
258
  const model = approvedModels.get(value.modelAlias)
219
259
  if (model === undefined) throw new Error("Kimi model is not allowed")
220
260
  return {
221
- brokerAddress: value.brokerAddress,
222
- grant: {
223
- provider: "kimi", runId: value.runId, networkId: value.networkId,
224
- taskId: value.taskId, sessionId: value.sessionId,
225
- modelAlias: model.alias, model: model.model, ttlMs: value.ttlMs
226
- }
261
+ provider: "kimi", runId: value.runId, networkId: value.networkId,
262
+ taskId: value.taskId, sessionId: value.sessionId,
263
+ modelAlias: model.alias, model: model.model, ttlMs: value.ttlMs
227
264
  }
228
265
  }
229
266
 
@@ -273,6 +310,18 @@ function safeHost(value) {
273
310
  if (value !== "0.0.0.0" && value !== "127.0.0.1" && value !== "::") throw new Error("Invalid Kimi model broker host")
274
311
  return value
275
312
  }
313
+ /** @param {string | undefined} value @param {string} name @returns {string} */
314
+ function bindSetting(value, name) {
315
+ const bind = safeSetting(value, name).trim()
316
+ // A host interface only: no port, no whitespace, no wildcard. Loopback is
317
+ // the load-time sentinel; an explicit task-reachable address is the only
318
+ // functional choice.
319
+ if (bind === "0.0.0.0" || bind === "::") throw new Error(`${name} must not be a wildcard bind`)
320
+ if (bind !== "127.0.0.1" && bind !== "::1" && !/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(bind)) {
321
+ throw new Error(`${name} must be an explicit interface address`)
322
+ }
323
+ return bind
324
+ }
276
325
  /** @param {string | undefined} value @param {number} fallback @returns {number} */
277
326
  function portValue(value, fallback) {
278
327
  if (value === undefined) return fallback
@@ -316,10 +365,3 @@ function boundedClose(server) {
316
365
  new Promise((resolve) => { const timer = setTimeout(resolve, 1000); timer.unref() })
317
366
  ])
318
367
  }
319
- /** @param {Map<string, import("node:http").Server>} listeners @param {string} token */
320
- async function closeListener(listeners, token) {
321
- const listener = listeners.get(token)
322
- listeners.delete(token)
323
- if (listener === undefined) return
324
- await boundedClose(listener)
325
- }
@@ -36,10 +36,7 @@ export function createGrantStore(options = {}) {
36
36
  activate(token) {
37
37
  const grant = grants.get(token)
38
38
  if (!grant) throw new Error("Broker grant unknown token")
39
- if (grant.expiresAt < now()) {
40
- grants.delete(token)
41
- throw new Error("Broker grant expired")
42
- }
39
+ if (grant.expiresAt < now()) throw new Error("Broker grant expired")
43
40
  if (grant.active) throw new Error("Broker grant already active")
44
41
  grant.active = true
45
42
  return {...grant}
@@ -50,10 +47,16 @@ export function createGrantStore(options = {}) {
50
47
  if (!grant) throw new Error("Broker grant unknown token")
51
48
  if (grant.provider !== context.provider || grant.networkId !== context.networkId || (context.runId !== undefined && grant.runId !== context.runId)) throw new Error("Broker grant lineage denied")
52
49
  if (!grant.active) throw new Error("Broker grant pending")
53
- if (grant.expiresAt < now()) {
54
- grants.delete(token)
55
- throw new Error("Broker grant expired")
56
- }
50
+ if (grant.expiresAt < now()) throw new Error("Broker grant expired")
51
+ return {...grant}
52
+ },
53
+ /** @param {string} token @param {number} ttlMs */
54
+ renew(token, ttlMs) {
55
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1 || ttlMs > 3_600_000) throw new Error("Invalid broker grant")
56
+ const grant = grants.get(token)
57
+ if (!grant) throw new Error("Broker grant unknown token")
58
+ if (grant.expiresAt < now()) throw new Error("Broker grant expired")
59
+ grant.expiresAt = now() + ttlMs
57
60
  return {...grant}
58
61
  },
59
62
  /** @param {string} token */
@@ -93,6 +93,20 @@ export async function startModelBroker(options = {}) {
93
93
  sendJson(response, 201, {token, generation, port: address.port})
94
94
  return
95
95
  }
96
+ if (request.url?.startsWith("/admin/grants/") && request.url.endsWith("/renew") && request.method === "POST") {
97
+ requireAdmin(request.headers.authorization, adminToken)
98
+ const token = decodeURIComponent(request.url.slice("/admin/grants/".length, -"/renew".length))
99
+ const body = await readJson(request, shutdown.signal)
100
+ if (!isRecord(body)) throw new Error("Invalid broker grant")
101
+ const renewed = grants.renew(token, body.ttlMs)
102
+ const runtime = grantRuntimes.get(token)
103
+ if (runtime === undefined) throw new Error("Broker grant unknown token")
104
+ clearTimeout(runtime.timer)
105
+ runtime.timer = setTimeout(() => { void revokeGrant(token) }, Math.max(1, renewed.expiresAt - Date.now()))
106
+ runtime.timer.unref()
107
+ sendJson(response, 200, {ok: true})
108
+ return
109
+ }
96
110
  if (request.url?.startsWith("/admin/grants/") && request.method === "DELETE") {
97
111
  requireAdmin(request.headers.authorization, adminToken)
98
112
  const token = decodeURIComponent(request.url.slice("/admin/grants/".length))
@@ -27,7 +27,7 @@ export function createProvider(name, providerArguments, prompt, resumeSession, e
27
27
  return {...command, name: "opencode", parse: createOpenCodeParser(), sessionId: createOpenCodeSessionId()}
28
28
  }
29
29
  if (name === "kimi") {
30
- const command = buildKimiCommand(providerArguments, prompt, resumeSession)
30
+ const command = buildKimiCommand(providerArguments, prompt, resumeSession, environment)
31
31
  return {...command, name: "kimi", parse: createKimiParser(), sessionId: createKimiSessionId()}
32
32
  }
33
33
  throw new Error(`--provider must be one of: ${PROVIDERS.join(", ")}`)
@@ -20,14 +20,24 @@ export function validateKimiProviderArguments(providerArguments) {
20
20
  throw new Error("Threadwire owns Kimi prompt, output, session, model configuration, permissions, and extensions")
21
21
  }
22
22
 
23
- /** @param {string[]} providerArguments @param {string} prompt @param {string | undefined} [resumeSession] */
24
- export function buildKimiCommand(providerArguments, prompt, resumeSession) {
25
- const selection = validateKimiProviderArguments(providerArguments)
23
+ const OWNED_ARGUMENTS = new Set([
24
+ "--prompt", "-p", "--output-format", "--session", "-S", "--resume-session", "--resume", "-r"
25
+ ])
26
+
27
+ /**
28
+ * Native Kimi uses caller-owned provider configuration. Threadwire owns only
29
+ * the prompt, stream output, and explicit resume handle.
30
+ * @param {string[]} providerArguments @param {string} prompt @param {string | undefined} [resumeSession] @param {NodeJS.ProcessEnv} [environment]
31
+ */
32
+ export function buildKimiCommand(providerArguments, prompt, resumeSession, environment = process.env) {
33
+ rejectNativeOwnedArguments(providerArguments)
26
34
  if (resumeSession !== undefined && !SESSION_PATTERN.test(resumeSession)) throw new Error("Kimi session ID is invalid")
27
35
  return {
28
- executable: EXECUTABLE,
36
+ executable: environment.THREADWIRE_KIMI_BIN?.length ? environment.THREADWIRE_KIMI_BIN : EXECUTABLE,
29
37
  arguments: [
30
- "--model", selection.modelAlias,
38
+ ...providerArguments,
39
+ // Threadwire's public resume option maps to Kimi Code's native session option.
40
+ // Kimi Code 0.29.2 does not provide a --resume-session flag.
31
41
  ...(resumeSession === undefined ? [] : ["--session", resumeSession]),
32
42
  "--prompt", prompt,
33
43
  "--output-format", "stream-json"
@@ -35,6 +45,16 @@ export function buildKimiCommand(providerArguments, prompt, resumeSession) {
35
45
  }
36
46
  }
37
47
 
48
+ /** @param {string[]} arguments_ */
49
+ function rejectNativeOwnedArguments(arguments_) {
50
+ if (arguments_.includes("--")) throw new Error("Kimi provider argument terminator is not allowed")
51
+ if (arguments_.some((argument) => OWNED_ARGUMENTS.has(argument)
52
+ || argument.startsWith("--prompt=") || argument.startsWith("--output-format=")
53
+ || argument.startsWith("--session=") || argument.startsWith("--resume-session=") || argument.startsWith("--resume="))) {
54
+ throw new Error("Threadwire owns Kimi prompt, streaming output, and session options")
55
+ }
56
+ }
57
+
38
58
  /** @returns {(record: unknown) => string | undefined} */
39
59
  export function createKimiSessionId() {
40
60
  let emitted = false
@@ -105,7 +125,7 @@ function recognizeKimiRecord(record, state) {
105
125
  const parsed = parseToolCall(toolCall)
106
126
  if (parsed === undefined) return emptyRecognition()
107
127
  state?.tools.set(parsed.id, parsed.name)
108
- events.push({type: "tool", phase: "started", name: parsed.name, key: `tool:${parsed.id}`})
128
+ events.push({type: "tool", phase: "started", name: parsed.name, key: `tool:${parsed.id}`, ...(parsed.detail === undefined ? {} : {detail: parsed.detail})})
109
129
  }
110
130
  }
111
131
  if (events.length === 0) return emptyRecognition()
@@ -132,14 +152,26 @@ function recognizeKimiRecord(record, state) {
132
152
  return emptyRecognition()
133
153
  }
134
154
 
135
- /** @param {unknown} value @returns {{id: string, name: string} | undefined} */
155
+ /** @param {unknown} value @returns {{id: string, name: string, detail?: string} | undefined} */
136
156
  function parseToolCall(value) {
137
157
  if (!isRecord(value) || !exactKeys(value, ["type", "id", "function"])
138
158
  || value.type !== "function" || typeof value.id !== "string" || !TOOL_ID_PATTERN.test(value.id)
139
159
  || !isRecord(value.function) || !exactKeys(value.function, ["name", "arguments"])
140
160
  || typeof value.function.name !== "string" || !TOOL_NAME_PATTERN.test(value.function.name)
141
161
  || typeof value.function.arguments !== "string") return undefined
142
- return {id: value.id, name: value.function.name}
162
+ return {id: value.id, name: value.function.name, ...toolDetail(value.function.arguments)}
163
+ }
164
+
165
+ /** @param {string} serialized @returns {{detail?: string}} */
166
+ function toolDetail(serialized) {
167
+ let arguments_
168
+ try { arguments_ = JSON.parse(serialized) } catch { return {} }
169
+ if (!isRecord(arguments_)) return {}
170
+ for (const key of ["command", "file_path", "path", "pattern", "query", "description"]) {
171
+ const value = arguments_[key]
172
+ if (typeof value === "string" && value.length > 0) return {detail: Array.from(value).slice(0, 512).join("")}
173
+ }
174
+ return {}
143
175
  }
144
176
 
145
177
  /** @param {WorkerEvent[]} events @param {KimiRecordState | undefined} state @returns {WorkerEvent[]} */
package/src/run-worker.js CHANGED
@@ -9,7 +9,7 @@ const DEFAULT_MAX_STDOUT_RECORD_BYTES = 1_048_576
9
9
  const DEFAULT_TERMINATION_GRACE_PERIOD_MS = 5_000
10
10
 
11
11
  /**
12
- * @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void | Promise<void>, onSpawn?: (pid: number) => void, onRecord?: (record: unknown) => void | Promise<void>, onStdoutChunk?: (chunk: Buffer) => void | Promise<void>, onStderrChunk?: (chunk: Buffer) => void | Promise<void>, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number, terminationGracePeriodMs?: number, setTimer?: (callback: () => void, milliseconds: number) => unknown, clearTimer?: (handle: unknown) => void, platform?: NodeJS.Platform, killProcess?: (pid: number, signal: NodeJS.Signals) => boolean}} RunWorkerOptions
12
+ * @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, provider?: string, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void | Promise<void>, onSpawn?: (pid: number) => void, onRecord?: (record: unknown) => void | Promise<void>, onStdoutChunk?: (chunk: Buffer) => void | Promise<void>, onStderrChunk?: (chunk: Buffer) => void | Promise<void>, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number, terminationGracePeriodMs?: number, setTimer?: (callback: () => void, milliseconds: number) => unknown, clearTimer?: (handle: unknown) => void, platform?: NodeJS.Platform, killProcess?: (pid: number, signal: NodeJS.Signals) => boolean}} RunWorkerOptions
13
13
  */
14
14
 
15
15
  /** @param {RunWorkerOptions} options @returns {Promise<number>} */
@@ -25,7 +25,7 @@ export function runWorker(options) {
25
25
  const child = spawnImplementation(options.executable, options.arguments, {
26
26
  cwd: options.cwd,
27
27
  detached: useDetachedProcessGroup,
28
- env: childEnvironment(options.environment ?? process.env),
28
+ env: childEnvironment(options.environment ?? process.env, options.provider),
29
29
  shell: false,
30
30
  stdio: ["ignore", "pipe", "pipe"]
31
31
  })
@@ -214,9 +214,9 @@ export class StdoutRecordTooLargeError extends Error {
214
214
  }
215
215
  }
216
216
 
217
- /** @param {NodeJS.ProcessEnv} environment */
218
- export function childEnvironment(environment = process.env) {
219
- const childEnvironment = buildProviderEnvironment(environment)
217
+ /** @param {NodeJS.ProcessEnv} environment @param {string | undefined} [provider] */
218
+ export function childEnvironment(environment = process.env, provider) {
219
+ const childEnvironment = buildProviderEnvironment(environment, provider === undefined ? {} : {provider})
220
220
  // Mark the provider child tree active so a nested provider CLI invoked from
221
221
  // the /opt/data/bin front-door shim runs its libexec adapter directly instead
222
222
  // of relaying again. Threadwire always spawns the libexec adapter, never the
@@ -222,32 +222,38 @@ export function parseTelegramRequestTimeoutMs(environment) {
222
222
  /**
223
223
  * Build the environment passed to the provider child. Keeps operational host
224
224
  * variables needed by providers, but never forwards Telegram or ingress secrets.
225
+ * Kimi's native CLI has a deliberately narrow configuration contract. Its
226
+ * credentials and state directory are never useful to another provider or to
227
+ * an isolated Kimi worker.
225
228
  * @param {NodeJS.ProcessEnv} source
229
+ * @param {{provider?: string, isolated?: boolean}} [options]
226
230
  * @returns {NodeJS.ProcessEnv}
227
231
  */
228
- export function buildProviderEnvironment(source) {
232
+ export function buildProviderEnvironment(source, options = {}) {
233
+ const preservesNativeKimiConfiguration = options.provider === "kimi" && options.isolated !== true
229
234
  /** @type {NodeJS.ProcessEnv} */
230
235
  const environment = {}
231
236
  for (const [key, value] of Object.entries(source)) {
232
237
  if (value === undefined) continue
233
- if (isIngressSecretKey(key)) continue
238
+ if (isIngressSecretKey(key, preservesNativeKimiConfiguration)) continue
234
239
  environment[key] = value
235
240
  }
236
241
  return environment
237
242
  }
238
243
 
239
- /** @param {string} key */
240
- function isIngressSecretKey(key) {
244
+ /** @param {string} key @param {boolean} preservesNativeKimiConfiguration */
245
+ function isIngressSecretKey(key, preservesNativeKimiConfiguration) {
246
+ if (key === "THREADWIRE_KIMI_BIN") return !preservesNativeKimiConfiguration
247
+ if (key === "KIMI_API_KEY" || key === "MOONSHOT_API_KEY" || key === "KIMI_CODE_HOME") {
248
+ return !preservesNativeKimiConfiguration
249
+ }
241
250
  if (FILE_BACKED_SETTINGS.some((name) => key === `${name}_FILE`)) return true
242
251
  if (key === "THREADWIRE_REQUIRE_CODEX_ISOLATION") return true
243
252
  if (
244
253
  key.startsWith("THREADWIRE_ISOLATED_RUNTIME_")
245
254
  || key.startsWith("THREADWIRE_MODEL_BROKER_")
246
255
  || key.startsWith("THREADWIRE_KIMI_")
247
- || key === "KIMI_MODEL_API_KEY"
248
- || key === "KIMI_API_KEY"
249
- || key === "MOONSHOT_API_KEY"
250
- || key === "KIMI_CODE_HOME"
256
+ || key.startsWith("KIMI_")
251
257
  || key === "THREADWIRE_RELAY_WORKER_IMAGE"
252
258
  || key === "THREADWIRE_ALLOWED_WORKTREE_ROOTS"
253
259
  || key === "THREADWIRE_WORKTREE_VOLUME"