threadwire 0.1.8 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.9 - 2026-07-28
6
+
7
+ - Add native Kimi Code subscription support through official OAuth, a distinct
8
+ narrow Kimi model broker, an immutable pinned worker/auth image set, and a
9
+ mandatory provider-dedicated isolated runtime with no direct fallback.
10
+ - Normalize only trusted Kimi assistant/tool/session envelopes, preserve exact
11
+ native continuation and Telegram topic routing, and suppress thinking, raw
12
+ tool payloads, arbitrary metadata, stderr/stdout, grants, and OAuth material.
13
+ - Bind Kimi grants and authenticated session state to provider, task, session,
14
+ run network, approved alias, and wire model; add adversarial fresh/resume,
15
+ failure, cancellation, refresh, redaction, network, and cross-provider tests.
16
+
5
17
  ## 0.1.8 - 2026-07-26
6
18
 
7
19
  - Add `threadwire run --transcript <path>` to durably capture the exact normalized
package/README.md CHANGED
@@ -60,11 +60,11 @@ Use the local launcher. Install or refresh it after updating Threadwire with `np
60
60
  -- --model example-model
61
61
  ```
62
62
 
63
- The target always has the form `telegram:<chat-id>:<thread-id>` for topics, or `telegram:<chat-id>` for direct messages (DMs) and ordinary chats. The chat ID is always required and must be a nonzero signed integer. The thread ID is optional and valid only as the explicit topic suffix: when present it must be a positive safe integer, and a DM target simply omits it — a missing thread ID is never an error. `--provider` must be exactly one of `codex`, `claude`, or `opencode`. Malformed or missing provider, chat ID, or thread ID arguments are hard failures (exit code 2) reported through Threadwire's normal error path with fixed messages; they fail before the Telegram token is read, before any transport is created, and before any provider is launched, and the messages never echo the bot token or the untrusted argument value. `--process-number` accepts an explicit positive safe integer for automation; when omitted, Threadwire uses its launcher PID. Every Telegram message is labeled consistently, such as `[P42] …`, including chunked continuations.
63
+ The target always has the form `telegram:<chat-id>:<thread-id>` for topics, or `telegram:<chat-id>` for direct messages (DMs) and ordinary chats. The chat ID is always required and must be a nonzero signed integer. The thread ID is optional and valid only as the explicit topic suffix: when present it must be a positive safe integer, and a DM target simply omits it — a missing thread ID is never an error. `--provider` must be exactly one of `codex`, `claude`, `kimi`, or `opencode`. Malformed or missing provider, chat ID, or thread ID arguments are hard failures (exit code 2) reported through Threadwire's normal error path with fixed messages; they fail before the Telegram token is read, before any transport is created, and before any provider is launched, and the messages never echo the bot token or the untrusted argument value. `--process-number` accepts an explicit positive safe integer for automation; when omitted, Threadwire uses its launcher PID. Every Telegram message is labeled consistently, such as `[P42] …`, including chunked continuations.
64
64
 
65
- Pass `--workspace-profile <name>` only when invoking Threadwire inside the container-native runtime, for example with `node bin/threadwire.js run` as shown in [Container runtime](docs/container-runtime.md). A profile selects a reviewed entry from the checked-in [threadwire.workspace-profiles.json](threadwire.workspace-profiles.json). It supplies the absolute in-container repository root, the exact in-container working directory, and the allowlisted providers for that workspace. Threadwire validates the selected profile, rejects malformed or unknown names, verifies the configured workspace exists and is still a healthy Git worktree, and records the selected profile plus immutable revision/worktree identity in the local activity log before provider spawn. The checked-in `container-runtime` default targets `/workspace/threadwire` inside the container-native runtime and currently allowlists only `codex`.
65
+ Pass `--workspace-profile <name>` only when invoking Threadwire inside the container-native runtime, for example with `node bin/threadwire.js run` as shown in [Container runtime](docs/container-runtime.md). A profile selects a reviewed entry from the checked-in [threadwire.workspace-profiles.json](threadwire.workspace-profiles.json). It supplies the absolute in-container repository root, the exact in-container working directory, and the allowlisted providers for that workspace. Threadwire validates the selected profile, rejects malformed or unknown names, verifies the configured workspace exists and is still a healthy Git worktree, and records the selected profile plus immutable revision/worktree identity in the local activity log before provider spawn. The checked-in `container-runtime` default targets `/workspace/threadwire` inside the container-native runtime and allowlists `codex` and isolated `kimi`.
66
66
 
67
- Provider tool-start and tool-finish events do not produce Telegram messages by default. Pass the boolean `--tool-messages` flag to opt in, as shown above. When enabled, each tool is one concise, silent status line rather than a pair of noisy start/finish notices: when a tool starts, Threadwire sends `🛠 [P42] Tool: <safe command or tool description>` (for example `🛠 [P42] Tool: command — git status --short`), and when that same tool finishes, it edits the original message in place so it begins `✅` instead — no second completion message is sent, and Telegram edits raise no notification. The command/description shows a safe CLI-style preview with credential-bearing values (tokens, passwords, keys, authorization/cookie forms, credentials in URLs) redacted and control characters stripped; only normalized Codex command-completion output may also be appended to that same edited status as a Telegram-native expandable HTML blockquote with the visible label `Output →`; it is credential-redacted, HTML-escaped, and Unicode-safe bounded. Stderr, reasoning, and every other raw provider result remain excluded. Use `--max-output-length <positive-integer>` to truncate only that preview Unicode-safely; omit the option for unlimited tool detail. A prompt may instead come from `--prompt-file` or piped stdin, and every source rejects blank/whitespace content. Arguments following `--` pass to the selected provider, except output-stream options owned by Threadwire.
67
+ Provider tool-start and tool-finish events do not produce Telegram messages by default. Pass the boolean `--tool-messages` flag to opt in, as shown above. When enabled, each tool is one concise, silent status line rather than a pair of noisy start/finish notices: when a tool starts, Threadwire sends `🛠 [P42] Tool: <safe command or tool description>` (for example `🛠 [P42] Tool: command — git status --short`), and when that same tool finishes, it edits the original message in place so it begins `✅` instead — no second completion message is sent, and Telegram edits raise no notification. The command/description shows a safe CLI-style preview with credential-bearing values (tokens, passwords, keys, authorization/cookie forms, credentials in URLs) redacted and control characters stripped; only normalized Codex command-completion output may also be appended to that same edited status as a Telegram-native expandable HTML blockquote with the visible label `Output →`; it is credential-redacted, HTML-escaped, and Unicode-safe bounded. Stderr, reasoning, and every other raw provider result remain excluded. Use `--max-output-length <positive-integer>` to truncate only that preview Unicode-safely; omit the option for unlimited tool detail. A prompt may instead come from `--prompt-file` or piped stdin, and every source rejects blank/whitespace content. Arguments following `--` pass to the selected provider, except output-stream options owned by Threadwire. Kimi is narrower: it always uses the dedicated isolated subscription runtime and accepts only one server-approved model alias through `--model <alias>` (or `-m <alias>`); Threadwire owns its prompt, output, session, permissions, configuration, tools, skills, plugins, MCP, and directories.
68
68
 
69
69
  Threadwire directly spawns the structurally separate terminal adapters `/opt/data/libexec/threadwire/codex`, `/opt/data/libexec/threadwire/claude`, or `/opt/data/libexec/threadwire/opencode-local-fleet` with an argv array and `shell: false`. These libexec adapters hold the real provider-CLI logic and are never on a worker's `PATH`, so relaying can never recurse back into the front-door shims. Each spawn also sets `THREADWIRE_ACTIVE=1` in the provider child environment. The executable can be overridden per provider with `THREADWIRE_CODEX_BIN`, `THREADWIRE_CLAUDE_BIN`, or `THREADWIRE_OPENCODE_BIN` for staged cutover and rollback. It does not impose a worker timeout. `SIGINT` and `SIGTERM` are forwarded to the worker as explicit cancellation.
70
70
 
@@ -1,9 +1,10 @@
1
1
  # Telegram Ingress for Threadwire
2
2
 
3
3
  Standalone Threadwire webhook service that accepts Telegram Bot API updates.
4
- Native providers use the existing worker path; when isolated-runtime authority
5
- is configured, Codex uses isolated preflight/run exclusively with no native
6
- fallback. Hermes is not involved.
4
+ Codex, Claude, and OpenCode retain the existing worker behavior. Kimi always
5
+ uses its dedicated isolated preflight/run authority with no native fallback;
6
+ a missing Kimi runtime fails before provider or transport construction. Hermes
7
+ is not involved.
7
8
 
8
9
  ## Important bot ownership
9
10
 
@@ -44,7 +45,7 @@ The process binds to **loopback (`127.0.0.1`) by default**. Expose it only throu
44
45
  - **Residual restart boundary:** this store is process-local memory only. A process restart clears reservations and completed IDs, so Telegram redelivery after restart can launch work again until IDs are re-observed. This is not durable multi-instance replay protection.
45
46
  - Bound concurrent spawned workers (default 4, configurable). When at capacity, reject with `503` without launching. The concurrency slot is released when the background worker settles (success or failure after spawn), or immediately on pre-spawn failure.
46
47
  - Both chat and sender allowlists are mandatory and exact; missing IDs fail closed.
47
- - Command grammar is exact: `/code[optional @bot] <codex|claude|opencode> <nonblank prompt>`.
48
+ - Command grammar is exact: `/code[optional @bot] <codex|claude|kimi|opencode> <nonblank prompt>`.
48
49
  - Direct target is derived only from authenticated `message.chat.id` and optional positive `message_thread_id`.
49
50
  - DM/chat: `telegram:<chat-id>`
50
51
  - Topic: `telegram:<chat-id>:<thread-id>`
@@ -82,6 +83,8 @@ Optional:
82
83
  | `THREADWIRE_TELEGRAM_REQUEST_TIMEOUT_MS` | Deadline for each outbound Telegram request, including response parsing (default `30000`, positive safe integer no greater than `2147483647`) |
83
84
  | `THREADWIRE_UPDATE_ID_CAPACITY` | Max retained completed `update_id` entries (default `10000`) |
84
85
  | `THREADWIRE_UPDATE_ID_TTL_MS` | Retention TTL for completed `update_id` entries in ms (default `86400000`) |
86
+ | `THREADWIRE_KIMI_ISOLATED_RUNTIME_URL` | Dedicated Kimi supervisor URL; Compose defaults to `http://kimi-isolated-runtime:8790` |
87
+ | `THREADWIRE_KIMI_ISOLATED_RUNTIME_CONTROL_TOKEN` | Dedicated Kimi control token. When absent, `/code kimi` remains recognized but fails closed before launch. |
85
88
 
86
89
  ## Run
87
90
 
@@ -111,6 +114,7 @@ The wrapper starts the service exported by the importable
111
114
  /code codex write a hello world program
112
115
  /code@your_bot claude explain quantum computing
113
116
  /code opencode build a todo app
117
+ /code kimi inspect this repository
114
118
  ```
115
119
 
116
120
  ## Modules
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {startKimiModelBroker} from "../src/kimi-model-broker.js"
4
+
5
+ await startKimiModelBroker()
@@ -199,3 +199,25 @@ Back them up using an operator-approved volume backup process before migration.
199
199
  `docker compose down --volumes` is destructive and removes the container-owned
200
200
  repository and provider state; use it only for an explicitly confirmed teardown.
201
201
  Never export or archive mounted secret files with workspace/provider backups.
202
+
203
+
204
+ ## Kimi subscription containers
205
+
206
+ Kimi is intentionally excluded from the same-UID container-native provider
207
+ launch described above. Never mount `threadwire-kimi-oauth`, its access/refresh
208
+ tokens, or the auth home into the Threadwire runtime, supervisor, worker,
209
+ workspace, or session-state volume. Kimi uses four immutable build targets:
210
+ `kimi-auth`, `kimi-model-broker`, `kimi-relay-worker`, and `isolated-runtime`.
211
+ The first two share UID/GID 10003 and only the OAuth volume; the worker uses
212
+ UID/GID 10002 with only the selected worktree and provider-bound state; the
213
+ root supervisor alone receives the Docker socket. The broker/auth service uses
214
+ Node `22.19.0` and exactly `@moonshot-ai/kimi-code@0.29.2` with the checked npm
215
+ integrity and archive SHA-512 in the Dockerfile.
216
+
217
+ Enable the checked-in override with `--profile kimi` only after the OAuth/model
218
+ operator flow in [Isolated provider runtime](isolated-provider-runtime.md).
219
+ The default workspace profile allowlists Kimi because isolation is mandatory;
220
+ without the dedicated Kimi control token/client, both CLI and Telegram Kimi
221
+ jobs fail closed rather than using the native child path. Keep broker egress
222
+ restricted to official Kimi Code subscription/auth endpoints and do not provide
223
+ a Moonshot API key, custom base URL, Docker socket, or ingress secret.
@@ -135,3 +135,98 @@ Run `npm run all-checks`, `npm run verify:package`, and
135
135
  production relay-worker, and fake-upstream image variables documented
136
136
  by that script. E2E uses host-volume canaries and fake-upstream authorization
137
137
  logs; worker self-report is not the sole assertion.
138
+
139
+
140
+ ## Native Kimi Code subscription boundary
141
+
142
+ Kimi uses the same proven supervisor mechanics but a distinct protocol and
143
+ credential service. `THREADWIRE_ISOLATED_PROVIDER=kimi` makes the supervisor
144
+ accept only Kimi preflights, select only `THREADWIRE_KIMI_RELAY_WORKER_IMAGE`,
145
+ issue grants only to `THREADWIRE_KIMI_MODEL_BROKER_URL`, and persist
146
+ provider-bound state in the separate Kimi namespace. Codex requests, sessions,
147
+ workers, grants, and volumes cannot be adopted by this service, and the Codex
148
+ broker remains unchanged.
149
+
150
+ The Kimi broker is not a generic credential proxy. It accepts only
151
+ `POST /v1/chat/completions`, validates a closed request schema and one approved
152
+ wire model, substitutes OAuth immediately before the fixed official
153
+ `https://api.kimi.com/coding/v1/chat/completions` request, and streams the
154
+ bounded response. A grant is pending until the inspected worker is ready and is
155
+ bound to provider, task hash, exact resume session (or fresh lineage), run,
156
+ Docker network, approved alias, and wire model. Revocation, timeout, shutdown,
157
+ or cleanup closes its private listener and active sockets.
158
+
159
+ The immutable Kimi worker image pins `@moonshot-ai/kimi-code@0.29.2` and verifies
160
+ the npm archive SHA-512 before installation. It receives no OAuth file, Docker
161
+ socket, Telegram credential, supervisor token, broker admin token, ambient
162
+ provider executable override, external network, or unrelated home directory.
163
+ Its fixed entrypoint owns `--model`, optional exact `--session`, `--prompt`,
164
+ `--output-format stream-json`, and an empty skills directory. Static deny rules,
165
+ workspace/config ancestry checks, a sanitized private home, read-only root,
166
+ and the existing mount/inode checks remain mandatory. There is no native unsafe
167
+ fallback and no `THREADWIRE_KIMI_BIN` control.
168
+
169
+ Only exact pinned stream records are normalized: assistant string content,
170
+ validated tool name/key start and finish, and one exact
171
+ `session.resume_hint`. Thinking, tool arguments/results, retry errors, version
172
+ or arbitrary metadata, malformed records, raw stdout/stderr, synthetic grants,
173
+ and OAuth material are discarded. The worker is the only reader of native Kimi
174
+ stdout and Docker receives only sanitized envelopes and fixed lifecycle events;
175
+ the supervisor returns `rawChunks: []` for every Kimi run.
176
+
177
+ ### Operator OAuth and model approval
178
+
179
+ Build and publish the `kimi-auth`, `kimi-model-broker`, `kimi-relay-worker`, and
180
+ existing `isolated-runtime` targets as immutable digests. Set distinct random
181
+ Kimi runtime and broker admin tokens of at least 32 characters, the Kimi image digests, and an allowlist
182
+ such as:
183
+
184
+ ```sh
185
+ export THREADWIRE_ALLOWED_KIMI_MODELS='{"default":{"model":"<verified-account-model>","protocol":"kimi"}}'
186
+ ```
187
+
188
+ OAuth writes only the named `threadwire-kimi-oauth` volume at the official
189
+ `credentials/kimi-code.json` location. Stop the broker during interactive auth
190
+ so the CLI and broker cannot race a refresh, then run the opt-in, workspace-free
191
+ auth service:
192
+
193
+ ```sh
194
+ docker compose -f compose.yaml -f compose.relay-write.yaml stop kimi-isolated-runtime kimi-model-broker
195
+ docker compose -f compose.yaml -f compose.relay-write.yaml --profile kimi-auth run --rm kimi-auth login
196
+ docker compose -f compose.yaml -f compose.relay-write.yaml --profile kimi-auth run --rm kimi-auth verify '<verified-account-model>'
197
+ docker compose -f compose.yaml -f compose.relay-write.yaml --profile kimi up --detach --wait
198
+ ```
199
+
200
+ Complete the official device flow directly in the operator terminal. Do not
201
+ copy device codes into tickets, chat, logs, environment files, or Threadwire.
202
+ The auth service has no workspace, ingress network, Docker socket, Threadwire
203
+ control token, or Telegram credential and uses Docker logging driver `none`.
204
+ `verify <account-model>` discards native output and prints only a fixed
205
+ success/failure; use it to confirm each exact account model value before mapping
206
+ that value to a server-approved Threadwire alias.
207
+ API keys, Moonshot platform mode, custom base URLs, discovery by ordinary jobs,
208
+ and arbitrary aliases are unsupported.
209
+
210
+ For logout or account rotation, stop the Kimi runtime and broker, run
211
+ `--profile kimi-auth run --rm kimi-auth logout` or `login`, verify approved
212
+ models, then restart the Kimi profile. The broker validates strict owner/mode,
213
+ regular-file, link-count, schema, and size constraints; refreshes are serialized
214
+ and atomically persisted, and corruption, symlinks, revocation, or unreadable
215
+ state makes health and preflight fail closed with redacted errors.
216
+
217
+ ### Kimi validation and rollback
218
+
219
+ Run `npm run test:kimi-isolated-runtime-e2e` from an approved Docker supervisor
220
+ boundary after supplying the documented immutable image variables. A real
221
+ account smoke must then cover fresh execution, capture of exactly one native
222
+ session ID, exact resume with that ID, an approved non-default alias if used,
223
+ Telegram DM/topic routing, cancellation, and confirmation that service logs and
224
+ activity/evidence contain no OAuth token, device code, raw Kimi record, tool
225
+ payload, or synthetic grant.
226
+
227
+ Rollback by stopping `kimi-isolated-runtime` and `kimi-model-broker`, removing
228
+ the `kimi` Compose profile/control variables, and reverting the Threadwire image.
229
+ Kimi commands then fail closed; Codex behavior is unaffected. Preserve the OAuth
230
+ and Kimi state volumes for a recoverable rollback. Remove them only as an
231
+ explicit credential/session teardown after OAuth revocation; never archive them
232
+ with workspace backups.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.8",
4
- "description": "Stream Codex, Claude, and OpenCode worker progress to an explicit Telegram destination",
3
+ "version": "0.1.9",
4
+ "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
7
7
  "claude",
8
8
  "cli",
9
9
  "codex",
10
+ "kimi",
10
11
  "opencode",
11
12
  "telegram"
12
13
  ],
@@ -24,6 +25,7 @@
24
25
  ],
25
26
  "bin": {
26
27
  "threadwire-isolated-runtime": "bin/isolated-runtime.js",
28
+ "threadwire-kimi-model-broker": "bin/kimi-model-broker.js",
27
29
  "threadwire-model-broker": "bin/model-broker.js",
28
30
  "threadwire": "bin/threadwire.js",
29
31
  "threadwire-telegram-webhook": "bin/telegram-webhook.js"
@@ -54,7 +56,8 @@
54
56
  "test:container-e2e": "sh docker/test/production-e2e.sh",
55
57
  "test:isolated-runtime-e2e": "sh docker/test/isolated-runtime-e2e.sh",
56
58
  "typecheck": "tsc --noEmit",
57
- "verify:package": "node scripts/verify-package.js"
59
+ "verify:package": "node scripts/verify-package.js",
60
+ "test:kimi-isolated-runtime-e2e": "sh docker/test/kimi-isolated-runtime-e2e.sh"
58
61
  },
59
62
  "devDependencies": {
60
63
  "@eslint/js": "10.0.1",
@@ -13,6 +13,7 @@ const npmEnvironment = {
13
13
  NPM_CONFIG_DRY_RUN: "false",
14
14
  npm_config_dry_run: "false"
15
15
  }
16
+ const packageTmpdir = process.env.THREADWIRE_PACKAGE_TMPDIR ?? tmpdir()
16
17
  const EXPECTED_FILES = [
17
18
  "CHANGELOG.md",
18
19
  "LICENSE",
@@ -21,6 +22,7 @@ const EXPECTED_FILES = [
21
22
  "bin/telegram-webhook.js",
22
23
  "bin/threadwire.js",
23
24
  "bin/isolated-runtime.js",
25
+ "bin/kimi-model-broker.js",
24
26
  "bin/model-broker.js",
25
27
  "docs/card-10520-plan.md",
26
28
  "docs/container-runtime.md",
@@ -48,6 +50,9 @@ const EXPECTED_FILES = [
48
50
  "src/isolated-runtime.js",
49
51
  "src/isolated-state.js",
50
52
  "src/isolated-worker.js",
53
+ "src/kimi-model-broker-policy.js",
54
+ "src/kimi-model-broker.js",
55
+ "src/kimi-oauth-store.js",
51
56
  "src/model-broker-policy.js",
52
57
  "src/model-broker.js",
53
58
  "src/mount-policy.js",
@@ -58,6 +63,7 @@ const EXPECTED_FILES = [
58
63
  "src/providers/codex.js",
59
64
  "src/providers/executable.js",
60
65
  "src/providers/index.js",
66
+ "src/providers/kimi.js",
61
67
  "src/providers/opencode.js",
62
68
  "src/relay.js",
63
69
  "src/relay-write.js",
@@ -78,7 +84,7 @@ const EXPECTED_FILES = [
78
84
 
79
85
  /** @returns {Promise<void>} */
80
86
  async function main() {
81
- const temporaryDirectory = await mkdtemp(join(tmpdir(), "threadwire-package-"))
87
+ const temporaryDirectory = await mkdtemp(join(packageTmpdir, "threadwire-package-"))
82
88
  const cacheDirectory = join(temporaryDirectory, "npm-cache")
83
89
 
84
90
  try {
@@ -133,6 +139,7 @@ async function main() {
133
139
  ], {
134
140
  env: {
135
141
  PATH: process.env.PATH,
142
+ HOME: process.env.HOME,
136
143
  NPM_CONFIG_DRY_RUN: "false",
137
144
  npm_config_dry_run: "false"
138
145
  },
@@ -24,13 +24,13 @@ export class ActivityLog {
24
24
  this.write({type: "workspace-selected", profile, repositoryRoot, revision, sourceIdentity})
25
25
  }
26
26
 
27
- /** @param {"codex" | "claude" | "opencode"} provider @param {number} pid */
27
+ /** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {number} pid */
28
28
  recordStarted(provider, pid) {
29
29
  if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Provider child PID is unavailable")
30
30
  this.write({type: "provider-started", provider, pid})
31
31
  }
32
32
 
33
- /** @param {"codex" | "claude" | "opencode"} provider @param {string} sessionId */
33
+ /** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {string} sessionId */
34
34
  recordSession(provider, sessionId) {
35
35
  if (!SESSION_ID_PATTERN.test(sessionId)) return
36
36
  this.write({type: "session-available", provider, sessionId})
@@ -42,7 +42,7 @@ export class ActivityLog {
42
42
  closeSync(this.fileDescriptor)
43
43
  }
44
44
 
45
- /** @param {{type: "workspace-selected", profile: string, repositoryRoot: string, revision: string, sourceIdentity: string} | {type: "provider-started", provider: "codex" | "claude" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "opencode", sessionId: string}} fact */
45
+ /** @param {{type: "workspace-selected", profile: string, repositoryRoot: string, revision: string, sourceIdentity: string} | {type: "provider-started", provider: "codex" | "claude" | "kimi" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "kimi" | "opencode", sessionId: string}} fact */
46
46
  write(fact) {
47
47
  if (this.closed) throw new Error("Activity log is closed")
48
48
  writeSync(this.fileDescriptor, `${JSON.stringify(fact)}\n`)
package/src/cli.js CHANGED
@@ -15,12 +15,13 @@ import {resolveWorkspaceProfile} from "./workspace-profile.js"
15
15
  import {WorkerControl} from "./worker-control.js"
16
16
  import {EvidenceStore} from "./evidence-store.js"
17
17
  import {ContextBudgetMetrics} from "./context-budget-metrics.js"
18
- import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
18
+ import {isolatedRuntimeClientFromEnvironment, kimiIsolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
19
+ import {kimiSessionEnvelopeId} from "./providers/kimi.js"
19
20
  import {validateRelayWriteProviderArguments} from "./relay-write.js"
20
21
  import {abortable} from "./absolute-deadline.js"
21
22
  import {NormalizedOutput} from "./normalized-output.js"
22
23
 
23
- const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
24
+ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
24
25
  [--process-number <positive-integer>] [--cwd <directory>]
25
26
  [--workspace-profile <name>]
26
27
  [--relay-write]
@@ -45,8 +46,9 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
45
46
  * evidenceStore?: EvidenceStore,
46
47
  * evidenceOwnerScope?: {destinationId: string, runId: string},
47
48
  * workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
48
- * workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations
49
- * isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
49
+ * workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations,
50
+ * isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
51
+ * kimiIsolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
50
52
  * }} MainDependencies
51
53
  */
52
54
 
@@ -112,6 +114,7 @@ export function parseArguments(arguments_) {
112
114
  throw new Error("--transcript and --activity-log must resolve to different paths")
113
115
  }
114
116
  if (parsed.relayWrite && parsed.workspaceProfile === undefined) throw new Error("--relay-write requires --workspace-profile")
117
+ if (parsed.provider === "kimi" && parsed.workspaceProfile === undefined) throw new Error("Kimi requires --workspace-profile")
115
118
  if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
116
119
  return /** @type {ParsedArguments} */ (parsed)
117
120
  }
@@ -199,15 +202,18 @@ export async function main(arguments_, dependencies = {}) {
199
202
  const resolvedWorkspace = parsed.workspaceProfile === undefined
200
203
  ? undefined
201
204
  : await resolveWorkspaceProfile(
202
- {provider: /** @type {"codex" | "claude" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile},
205
+ {provider: /** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile},
203
206
  dependencies.workspaceProfileOperations
204
207
  )
205
208
  if (validateOnly) return 0
206
- const isolatedRuntimeClient = parsed.relayWrite
207
- ? dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
209
+ const usesIsolatedRuntime = parsed.relayWrite || parsed.provider === "kimi"
210
+ const isolatedRuntimeClient = usesIsolatedRuntime
211
+ ? parsed.provider === "kimi"
212
+ ? dependencies.kimiIsolatedRuntimeClient ?? kimiIsolatedRuntimeClientFromEnvironment(sourceEnvironment)
213
+ : dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
208
214
  : undefined
209
- const isolatedPreflight = parsed.relayWrite
210
- ? await isolatedRuntimeClient?.preflight({
215
+ const isolatedPreflight = usesIsolatedRuntime
216
+ ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
211
217
  provider: parsed.provider,
212
218
  profile: /** @type {string} */ (parsed.workspaceProfile),
213
219
  repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
@@ -257,7 +263,7 @@ export async function main(arguments_, dependencies = {}) {
257
263
  parsed.providerArguments,
258
264
  prompt,
259
265
  parsed.resumeSession,
260
- parsed.relayWrite ? {} : providerEnvironment
266
+ usesIsolatedRuntime ? {} : providerEnvironment
261
267
  )
262
268
  if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
263
269
  if (parsed.workspaceProfile !== undefined) {
@@ -303,7 +309,7 @@ export async function main(arguments_, dependencies = {}) {
303
309
  },
304
310
  onRecord: (record) => {
305
311
  metrics.recordParsedProviderRecord("provider_stdout")
306
- const id = provider.sessionId(record)
312
+ const id = parsed.provider === "kimi" ? kimiSessionEnvelopeId(record) : provider.sessionId(record)
307
313
  if (id !== undefined) {
308
314
  admission.setContinuationHandle(id)
309
315
  activity?.recordSession(provider.name, id)
@@ -322,7 +328,7 @@ export async function main(arguments_, dependencies = {}) {
322
328
  return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
323
329
  }
324
330
  }
325
- const exitCode = parsed.relayWrite
331
+ const exitCode = usesIsolatedRuntime
326
332
  ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).run({
327
333
  preflightId: /** @type {{preflightId: string}} */ (isolatedPreflight).preflightId,
328
334
  prompt,
@@ -330,8 +336,8 @@ export async function main(arguments_, dependencies = {}) {
330
336
  ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession}),
331
337
  onEvent: workerOptions.onEvent,
332
338
  onRecord: /** @type {NonNullable<typeof workerOptions.onRecord>} */ (workerOptions.onRecord),
333
- ...(workerOptions.onStdoutChunk === undefined ? {} : {onStdoutChunk: workerOptions.onStdoutChunk}),
334
- ...(workerOptions.onStderrChunk === undefined ? {} : {onStderrChunk: workerOptions.onStderrChunk}),
339
+ ...(parsed.provider === "kimi" || workerOptions.onStdoutChunk === undefined ? {} : {onStdoutChunk: workerOptions.onStdoutChunk}),
340
+ ...(parsed.provider === "kimi" || workerOptions.onStderrChunk === undefined ? {} : {onStderrChunk: workerOptions.onStderrChunk}),
335
341
  ...(launchDeadline === undefined ? {} : {deadline: launchDeadline})
336
342
  })
337
343
  : await (dependencies.workerRunner ?? runWorker)(workerOptions)
@@ -121,6 +121,15 @@ export function isolatedRuntimeClientFromEnvironment(environment) {
121
121
  })
122
122
  }
123
123
 
124
+ /** @param {NodeJS.ProcessEnv} environment */
125
+ export function kimiIsolatedRuntimeClientFromEnvironment(environment) {
126
+ return new IsolatedRuntimeClient({
127
+ url: nonempty(environment.THREADWIRE_KIMI_ISOLATED_RUNTIME_URL, "THREADWIRE_KIMI_ISOLATED_RUNTIME_URL"),
128
+ controlToken: nonempty(environment.THREADWIRE_KIMI_ISOLATED_RUNTIME_CONTROL_TOKEN, "THREADWIRE_KIMI_ISOLATED_RUNTIME_CONTROL_TOKEN"),
129
+ timeoutMs: environment.THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS
130
+ })
131
+ }
132
+
124
133
  function normalizedUrl(value) {
125
134
  const url = new URL(value)
126
135
  if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("THREADWIRE_ISOLATED_RUNTIME_URL must use HTTP or HTTPS")