threadwire 0.1.13 → 0.1.15
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 +12 -0
- package/README.md +11 -2
- package/docs/capacity-admission.md +147 -0
- package/docs/development-container.md +77 -0
- package/package.json +1 -1
- package/scripts/verify-package.js +8 -0
- package/src/absolute-deadline.js +6 -3
- package/src/activity-log-status.js +291 -0
- package/src/activity-log.js +91 -3
- package/src/cli.js +153 -8
- package/src/docker-api.js +8 -0
- package/src/isolated-runtime-client.js +124 -4
- package/src/isolated-runtime.js +145 -11
- package/src/jsonl-record-spool.js +178 -0
- package/src/kimi-oauth-store.js +16 -0
- package/src/provider-capacity-codex.js +176 -0
- package/src/provider-capacity-kimi.js +105 -0
- package/src/provider-capacity.js +292 -0
- package/src/providers/index.js +110 -6
- package/src/providers/kimi.js +30 -0
- package/src/run-worker.js +286 -71
- package/src/telegram-ingress/core.js +51 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.15 - 2026-08-03
|
|
6
|
+
|
|
7
|
+
- Add machine-readable run health supervision: the existing append-only `--activity-log` now records safe timestamped controller/provider/session/activity/health/terminal facts with an explicit `controller-started` fact (the run controller's own PID, not an injected status-process PID). The `threadwire status --activity-log <absolute-path>` read-only command emits exactly one closed versioned JSON document (`version`, `state`, `provider`, `controllerPid`, `providerPid`, `continuationHandle`, `startedAt`, `lastActivityAt`, `quietForMs`, `health`, `terminal`). PIDs are recorded non-authoritative facts; status never inspects or signals processes. Health events carry a bounded disposition (`retrying`/`blocked`) plus safe category (`authentication`/`permission`/`rate-limit`/`quota`/`billing`/`model`/`network`/`protocol`/`unknown`) and optional `retryAfterMs` in milliseconds, extracted only from an explicit small list of safe provider record containers (top-level record and known nested `error`/`part`/`part.error`/`.data` shapes) — never recursive traversal and never message/text regex. Generic HTTP 429 → retrying/rate-limit; structured `exceeded_current_quota_error`/`insufficient_quota`/`insufficient_balance` → blocked quota/billing; ambiguous short fields such as `retry_after` (seconds) are never interpreted as milliseconds. If a provider protocol suppresses upstream error details (e.g., real Moonshot OpenCode sessions), no health fact is recorded and status stays running/unknown; Hermes must perform an independent bounded provider probe. Activity records are throttled (5 s) with guaranteed first-activity recording. Terminal facts always override earlier retrying/blocked health state and are written exactly once for local and isolated-runtime run paths; signal-derived exit codes 130/143 record as `cancelled`, all others as `completed` (0) or `failed` (nonzero). A later normal activity record clears a prior `retrying` health back to running; `blocked` stays sticky until terminal overrides it. The status command reads the log within a documented 1 MiB byte cap, never opens the file writable, skips at most one incomplete trailing JSONL record, rejects malformed/schema-unsafe records and overlong/malformed continuation handles, validates timestamps as safe non-negative Date-range integers and the injected clock similarly, and never claims stalled/dead from silence. Existing backward-compatible records without timestamps remain readable.
|
|
8
|
+
- Incrementally spool oversized provider JSONL records to private per-record temporary files after the bounded in-memory threshold, preserving byte order, UTF-8 chunk boundaries, malformed/incomplete-record diagnostics, and the exact absolute record cap while cleaning every per-record spill directory on success, failure, and cancellation. Protocol completion stops same-chunk scanning before any trailing record can exceed the cap or emit output. Ordinary valid provider records are no longer constrained to Telegram display sizes. Container-backed cancellation and failure teardown now retain exact Docker ownership through inspect, bounded TERM stop, terminal-state confirmation, and exact-container KILL escalation; CLI and Telegram deadline cancellation close the request side, await bounded terminal cleanup confirmation, and never treat an HTTP abort as cleanup success. Unconfirmed cleanup remains sealed for reconciliation and can never report success. Direct local-process behavior is unchanged.
|
|
9
|
+
- Extend the checked-in development image with build-time `oc-codex-multi-auth`, DeepSeek OpenAI-compatible, and xAI provider runtimes plus a root-owned secret-free OpenCode catalog for GPT-5.6 Sol/Terra/Luna, DeepSeek V4 Pro/Flash, Grok 4.5/Build/Code Fast, and Kimi K3 through OpenCode's built-in `moonshotai` provider. Kimi K3 keeps its 1,048,576-token context, 131,072-token output limit, default `max` reasoning effort, and `low`/`high`/`max` variants. Compose now requires portable source paths for native Codex auth, OpenCode auth, plugin OAuth, and the plugin's global account pool; refreshable files mount narrowly read-write, GitHub/context mounts stay read-only, per-project Codex account copies are disabled, and OpenCode database/session/cache state remains in the dedicated development home. The UID/GID 1000 initializer creates every nested mount target without receiving credentials. Threadwire remains the worker boundary; direct OpenCode is for authentication, discovery, and bounded smoke checks only.
|
|
10
|
+
|
|
11
|
+
## 0.1.14 - 2026-08-03
|
|
12
|
+
|
|
13
|
+
- Fix native Kimi runs hanging after a successful response: terminal completion is now explicit and provider-owned instead of derived only from process exit. Kimi's parser confirms completion from its stream-json protocol — a final assistant response (assistant content without further tool calls) plus the `session.resume_hint` handoff, in either order — and once confirmed, Threadwire preserves the session continuation handle, terminates the attempt-owned provider process group (`SIGTERM`), and emits exactly one `delegated_result`. Success settles only once the entire owned group is gone: a member that survives `SIGTERM` — including an attempt-owned grandchild after the direct provider exits — keeps the grace-period escalation running and receives `SIGKILL` against the exact group (liveness is probed with signal 0 against the group, never name matching or broad scans) before settlement, so no descendant leaks. Normal exit semantics are unchanged: a provider that exits nonzero before protocol completion remains a failure, and long-running tool activity stays unbounded with no generic idle timeout. Cancellation, consumer/relay failure, and launch failure clean up the complete attempt-owned process tree through the detached process group: parent `SIGINT`/`SIGTERM` cancellation now owns grace-period escalation (surviving members are `SIGKILL`ed against the exact group) and settles with the forwarded signal's exit status (never protocol success), and a consumer failure that lands after the direct child closed probes the exact group and terminates/escalates surviving descendants before rejecting the original error. `runWorker` also accepts an explicit `AbortSignal` (wired from the caller launch deadline where one exists): an abort terminates the exact owned group with the same grace/escalation and settles with the abort failure, never success. Settlement after any `SIGKILL` now waits for bounded cleanup confirmation — the exact process group is re-probed (or, where no process group exists such as win32, the direct child's own close is required) before success, failure, or cancellation settles; if owned processes cannot be confirmed gone within the bounded window (`cleanupConfirmationMs`, default 5s), the run settles as a cleanup failure, never success, preserving the original failure or cancellation context. Once the provider protocol confirms completion, the current stdout chunk stops draining immediately so trailing records can never produce duplicate output or consumer failures.
|
|
14
|
+
- Add a checked-in development container: the `app` service in `compose.dev.yaml` builds `docker/Dockerfile.dev` (Ubuntu 26.04 pinned by registry-verified digest, stable non-root `dev` user `1000:1000`, Node 22 LTS from the official SHA-256-verified tarball, distro GitHub CLI, and the npm-latest Codex/Claude/Kimi/OpenCode CLI baseline with Threadwire resolved through unversioned `npx threadwire`). The complete `DEV_HOME_PATH` home is bind-mounted at `/home/dev` with the checkout at `/home/dev/threadwire`, the host GitHub CLI config mounts read-only at `/home/dev/.config/gh` from the required `GH_CONFIG_SOURCE_PATH`, and an immutable agent-context snapshot mounts read-only at `/opt/agent-context` from the required `AGENT_CONTEXT_PATH`; the entrypoint proves the auth mount is read-only, initializes the HTTPS Git credential helper in the writable home, and links the context's AGENTS/CLAUDE guidance and trusted skills into every installed worker's discovery home (`.claude`, `.codex`, `.config/opencode`, `.kimi-code`). A one-shot `home-init` service precreates the provider parent directories as `1000:1000` on blank homes so Docker never creates the nested auth mount's parent root-owned. Project guidance and validation commands are documented in the new `AGENTS.md`. Production runtime files are unchanged.
|
|
15
|
+
- Add the read-only `threadwire capacity` command: it probes live account capacity for authenticated Codex (a bounded `codex app-server` JSON-RPC `initialize` + `account/rateLimits/read` exchange resolved like the Codex worker provider) and Kimi (`GET https://api.kimi.com/coding/v1/usages` with the OAuth bearer read through the hardened credential store's non-mutating `getCurrentAccessToken` — never refreshed, persisted, or tombstoned; a credential inside the 30-second validity margin classifies as `unavailable`), normalizes both into one closed short/long-window schema (`usedPercent`/`remainingPercent`/`resetAt`/duration, floored remaining percent, fail-closed exact-keys validation), and prints exactly one JSON report with per-provider `ok`/`unavailable`/`auth`/`protocol` statuses and a single deterministic admission selection. Selection rejects candidates below configurable short/long reserves (defaults 20%/10%), scores survivors by their most constrained window (absent long window non-constraining), and breaks ties by long-window remaining then provider name, so identical snapshots always reproduce the identical selection; no survivor yields `selection: null` and exit code 2. Tokens, raw credentials, account and plan data, and server error detail never enter output, errors, fixtures, or logs; the command writes nothing to disk, takes no session arguments, and leaves `run`, `evidence`, ingress, brokers, and the isolated runtime byte-identical. Provider/model/session pinning for tasks remains with the external worker ledger; see `docs/capacity-admission.md`.
|
|
16
|
+
|
|
5
17
|
## 0.1.13 - 2026-08-01
|
|
6
18
|
|
|
7
19
|
- Remove the default one-hour full-run deadline from the isolated runtime: when `THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS` (client) and `THREADWIRE_WORKER_TIMEOUT_MS` (supervisor) are unset or blank, a healthy running worker continues until natural completion, caller cancellation/disconnect, supervisor shutdown, or concrete failure. Setting either to a positive millisecond value enables the existing single absolute preflight+run deadline with unchanged validation and caps. Compose no longer inserts numeric timeout defaults. Preflight admission, per-operation Docker/network calls, state collection (30 seconds), and emergency cleanup stay independently bounded.
|
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ The target always has the form `telegram:<chat-id>:<thread-id>` for topics, or `
|
|
|
74
74
|
|
|
75
75
|
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 and session options owned by Threadwire. Native Kimi uses the caller's compatible configuration. Threadwire maps its public `--resume-session <exact-id>` option to Kimi's native `--session <exact-id>` option and owns `--prompt` plus `--output-format stream-json`. The caller environment or image must provide the Kimi executable at `/usr/local/bin/kimi` or set `THREADWIRE_KIMI_BIN`; a missing executable fails normally.
|
|
76
76
|
|
|
77
|
-
Threadwire directly spawns the structurally separate terminal adapters `/opt/data/libexec/threadwire/codex`, `/opt/data/libexec/threadwire/claude`, `/usr/local/bin/kimi`, 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`, `THREADWIRE_KIMI_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.
|
|
77
|
+
Threadwire directly spawns the structurally separate terminal adapters `/opt/data/libexec/threadwire/codex`, `/opt/data/libexec/threadwire/claude`, `/usr/local/bin/kimi`, 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`, `THREADWIRE_KIMI_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. Terminal completion is explicit and provider-owned rather than derived only from process exit: native Kimi confirms a completed run with a final assistant response (assistant content without further tool calls) plus its session handoff record in the stream. Once both records are confirmed, Threadwire preserves the session continuation handle, terminates the attempt-owned provider process group (`SIGTERM`), and emits exactly one `delegated_result`. Success settles only once the entire owned group is gone: members that survive `SIGTERM` — including attempt-owned grandchildren — keep the grace-period escalation running and receive `SIGKILL` against the exact group before settlement, so no descendant is leaked even when the direct provider exits first. A provider that exits nonzero before its protocol confirms completion keeps its failure status, and long-running tool activity remains unbounded: there is no generic idle timeout, only caller cancellation or an explicit deadline. Parent `SIGINT`/`SIGTERM` is owned cancellation, not just forwarding: the signal reaches the exact attempt-owned group, surviving members are escalated to `SIGKILL` after the grace period, and settlement keeps the forwarded signal's exit status (143/130 on Linux) only after the owned tree is gone. Relay/consumer failure cleans up the same owned tree — even when the failure lands after the direct child closed and only descendants survive — and the worker runner also accepts an explicit `AbortSignal`: an abort terminates the exact attempt-owned group with the same grace/escalation and settles with the abort failure, never success. After any `SIGKILL`, settlement waits for bounded cleanup confirmation — the exact group is re-probed, or on platforms without process groups the direct child's close is required — and cleanup that cannot be confirmed within the bounded window settles as failure, never success; this confirmation bound is not an idle or tool-activity timeout.
|
|
78
78
|
|
|
79
79
|
## Provider front-door shims
|
|
80
80
|
|
|
@@ -86,7 +86,9 @@ Pass `--resume-session <exact-provider-session-id>` to continue a stored Codex,
|
|
|
86
86
|
|
|
87
87
|
Pass `--transcript <path>` to capture the exact normalized, caller-facing JSONL protocol written to run stdout. The file is created privately (mode `0600`), must not already exist, and contains the same records in the same byte order as stdout. It never contains provider JSONL, reasoning, tool payloads, prompts, stderr, or credentials.
|
|
88
88
|
|
|
89
|
-
`--activity-log <path>` is different: it retains its append contract and writes
|
|
89
|
+
`--activity-log <path>` is different: it retains its append contract and writes safe timestamped lifecycle facts—provider start (`provider`, child `pid`), available native session ID, throttled activity markers, structured health events (bounded `retrying`/`blocked` disposition with safe category), and exactly one terminal fact. It is local-only and never enters Telegram or normalized stdout.
|
|
90
|
+
|
|
91
|
+
Inspect a live or completed activity log with the read-only `threadwire status --activity-log <absolute-path>` command. It emits exactly one closed versioned JSON document with `version`, `state` (`running`/`retrying`/`blocked`/`completed`/`failed`/`cancelled`/`unknown`), `provider`, `controllerPid`, `providerPid`, `continuationHandle`, `startedAt`, `lastActivityAt`, `quietForMs`, `health` (or `null`), and `terminal` (or `null`). The command derives state from the file only, never opens it writable, never inspects or signals processes, and never claims stalled/dead from silence. PIDs are recorded facts with explicit non-authoritative semantics.
|
|
90
92
|
|
|
91
93
|
Provider stdout is decoded as JSONL with a finite 1 MiB UTF-8 byte limit for each pending unterminated record. Exceeding it rejects the run, removes listeners and signal handlers, and terminates the provider without parsing or rendering the oversized content.
|
|
92
94
|
|
|
@@ -94,6 +96,8 @@ For normal `threadwire run` execution, Threadwire stdout is the parent-model adm
|
|
|
94
96
|
|
|
95
97
|
Scoped raw evidence artifacts are separate from both files above. Set the trusted operator setting `THREADWIRE_EVIDENCE_ROOT` for standalone runs to persist the delegated prompt and raw provider stdout/stderr as a scoped file-backed artifact; Telegram ingress uses its dedicated evidence volume automatically. The parent or owning Telegram topic receives only an opaque bearer handle. Retrieve later with `threadwire evidence read` or the authenticated `/evidence` Telegram command and an explicit bounded selector. See [Evidence artifacts](docs/evidence-artifacts.md) for permissions, quotas, retention, recovery, authorization, redaction, privacy, and cleanup behavior.
|
|
96
98
|
|
|
99
|
+
`threadwire capacity` is a read-only reporting command that probes live account capacity for authenticated Codex (`codex app-server` JSON-RPC `account/rateLimits/read`) and Kimi (`GET https://api.kimi.com/coding/v1/usages` with the installed OAuth credential), normalizes both into one closed short/long-window schema, and prints one deterministic reserve-gated admission selection as a single JSON document. It persists nothing, never prints tokens or account data, takes no session arguments, and does not change `run` provider selection; an external worker ledger consumes the report and owns pinning provider/model/session. See [Capacity admission](docs/capacity-admission.md) for the schema, tie-break, error taxonomy, and security boundary.
|
|
100
|
+
|
|
97
101
|
Telegram is the separate user-visible progress plane. Lifecycle notices, diagnostics, tool activity, and assistant streaming continue there independently; `--tool-messages` affects only Telegram. Execution-environment details, provider records and streams, lifecycle progress, diagnostics, tool names/arguments/details/output, stderr, prompts, narration/reasoning, and full transcripts never enter stdout. Threadwire's bounded error diagnostic remains on stderr. Help output is the sole non-run, human-readable stdout mode. This default is an intentional pre-1.0 protocol break from the former mixed Hermes JSONL stream.
|
|
98
102
|
|
|
99
103
|
The Telegram bot token is used only by Threadwire's notifier and both `TELEGRAM_BOT_TOKEN` and `THREADWIRE_TELEGRAM_BOT_TOKEN` are removed from every provider child environment. The three libexec adapters repeat that scrub as defense in depth. Other environment variables, including the providers' own authentication and normal process configuration, are preserved. Outbound Telegram fetch and response-body parsing share a 30-second deadline by default; set `THREADWIRE_TELEGRAM_REQUEST_TIMEOUT_MS` to a positive safe integer no greater than `2147483647` to override it.
|
|
@@ -106,6 +110,11 @@ The delivery queue has finite defaults of 100 pending notices and 256 KiB of pen
|
|
|
106
110
|
|
|
107
111
|
## Development
|
|
108
112
|
|
|
113
|
+
The checked-in development container is the `app` service in `compose.dev.yaml`
|
|
114
|
+
(Ubuntu 26.04 pinned by digest, non-root `1000:1000`, Node 22 LTS, npm-latest
|
|
115
|
+
coding CLI baseline). Setup, the read-only GitHub CLI and agent-context mount
|
|
116
|
+
contracts, and container commands are documented in [AGENTS.md](AGENTS.md).
|
|
117
|
+
|
|
109
118
|
Tests use synthetic provider records, a local fixture child, and injected transports. They never invoke Codex, Claude, or Telegram.
|
|
110
119
|
|
|
111
120
|
```sh
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Capacity admission
|
|
2
|
+
|
|
3
|
+
`threadwire capacity` probes live account capacity for the authenticated Codex
|
|
4
|
+
and Kimi providers, normalizes both into one closed schema, and makes a single
|
|
5
|
+
deterministic admission selection. It is a read-only reporting command: it
|
|
6
|
+
writes nothing to disk, changes no provider or runtime state, and never
|
|
7
|
+
launches, resumes, or selects a session. An external worker ledger consumes
|
|
8
|
+
the report and owns pinning the selected provider, model, and native session
|
|
9
|
+
across planning, implementation, debugging, and corrections; Threadwire
|
|
10
|
+
deliberately has no task-ledger ownership, no `--provider auto`, and no
|
|
11
|
+
capacity-based selection inside `threadwire run`.
|
|
12
|
+
|
|
13
|
+
## Command
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
threadwire capacity [--provider <codex|kimi>]...
|
|
17
|
+
[--short-reserve-percent <0-100>] # default 20
|
|
18
|
+
[--long-reserve-percent <0-100>] # default 10
|
|
19
|
+
[--timeout-ms <positive-integer>] # default 15000
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Stdout carries exactly one JSON document:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"version": 1,
|
|
27
|
+
"generatedAt": "2026-08-02T00:00:00.000Z",
|
|
28
|
+
"reserves": {"shortPercent": 20, "longPercent": 10},
|
|
29
|
+
"candidates": [
|
|
30
|
+
{
|
|
31
|
+
"provider": "codex",
|
|
32
|
+
"status": "ok",
|
|
33
|
+
"windows": {
|
|
34
|
+
"short": {"usedPercent": 40, "remainingPercent": 60, "resetAt": "2026-08-02T00:00:00.000Z", "windowDurationSeconds": 18000},
|
|
35
|
+
"long": {"usedPercent": 10, "remainingPercent": 90, "resetAt": "2026-08-09T00:00:00.000Z", "windowDurationSeconds": 604800}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"rejected": [{"provider": "kimi", "reason": "auth"}],
|
|
40
|
+
"selection": {"provider": "codex", "score": 60}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Exit code is `0` when `selection` is non-null and `2` for invalid arguments or
|
|
45
|
+
when no candidate survives; the report is still printed in the latter case.
|
|
46
|
+
`generatedAt` is the only wall-clock field: the same `candidates` and
|
|
47
|
+
`reserves` always reproduce the same `selection`, so the caller can re-verify a
|
|
48
|
+
pin before relying on it.
|
|
49
|
+
|
|
50
|
+
## Probes and normalization
|
|
51
|
+
|
|
52
|
+
- **Codex** spawns `codex app-server` and runs a bounded two-request JSON-RPC
|
|
53
|
+
exchange over stdio: `initialize`, then `account/rateLimits/read`. The
|
|
54
|
+
executable resolves via `THREADWIRE_CODEX_BIN` or the default libexec
|
|
55
|
+
adapter, exactly like the Codex worker provider; the anti-recursion default
|
|
56
|
+
is unchanged, so native container callers point the existing safe override
|
|
57
|
+
at the real CLI (for example `THREADWIRE_CODEX_BIN=/usr/local/bin/codex`).
|
|
58
|
+
Only the quota projection of the result is parsed: `rateLimits.primary` maps
|
|
59
|
+
to `short` and `rateLimits.secondary` to `long`; a `null` secondary yields a
|
|
60
|
+
short-only snapshot, and a secondary shorter than the primary fails closed.
|
|
61
|
+
Each window requires exactly `usedPercent`, `windowDurationMins`, and
|
|
62
|
+
`resetsAt` (epoch seconds, normalized to the canonical `resetAt` output
|
|
63
|
+
field); missing, obsolete, or extra keys fail closed, and unrelated result
|
|
64
|
+
or `rateLimits` metadata is neither read nor propagated.
|
|
65
|
+
- **Kimi** calls `GET https://api.kimi.com/coding/v1/usages` with the OAuth
|
|
66
|
+
bearer from the installed Kimi credential store, loaded through the same
|
|
67
|
+
hardened store as the model broker. The credential file resolves in order: a
|
|
68
|
+
non-empty absolute `THREADWIRE_KIMI_OAUTH_FILE` override, then the installed
|
|
69
|
+
CLI's canonical `${KIMI_CODE_HOME}/credentials/kimi-code.json`, then the
|
|
70
|
+
vendor default `${HOME}/.kimi-code/credentials/kimi-code.json`; missing,
|
|
71
|
+
relative, or NUL-containing candidates never qualify and resolution fails
|
|
72
|
+
closed as `auth` without logging any path. Only the quota projection of the
|
|
73
|
+
response is parsed: top-level `usage` is the long account quota (documented
|
|
74
|
+
weekly duration of 604,800 seconds) and the shortest `limits[]` window maps
|
|
75
|
+
to `short`; every returned window is validated (`window.duration` in
|
|
76
|
+
`TIME_UNIT_MINUTE`s, `detail` quota) but intermediate windows are ignored,
|
|
77
|
+
and normalization fails closed if the long duration would not exceed the
|
|
78
|
+
short. Quota fields are strict non-negative decimal strings validated with
|
|
79
|
+
`used + remaining === limit` and a positive limit, reset times must be
|
|
80
|
+
bounded ISO-8601, and remaining percent is floored so normalization never
|
|
81
|
+
overstates capacity. Unrelated top-level response fields are neither read
|
|
82
|
+
nor propagated into output or errors.
|
|
83
|
+
|
|
84
|
+
Both probes are bounded (capped lines/bytes, single absolute timeout) and fail
|
|
85
|
+
closed on any shape drift: exact keys within the parsed projection, forced
|
|
86
|
+
types, `0–100` percents, positive bounded durations, and reset times
|
|
87
|
+
normalized to ISO-8601 UTC.
|
|
88
|
+
|
|
89
|
+
## Selection semantics
|
|
90
|
+
|
|
91
|
+
`selectProvider` is pure (no I/O, no clock):
|
|
92
|
+
|
|
93
|
+
1. Candidates whose `status` is not `ok` are rejected with their status as the
|
|
94
|
+
reason (`unavailable`, `auth`, or `protocol`).
|
|
95
|
+
2. A candidate is rejected with `short-reserve` when its short-window
|
|
96
|
+
`remainingPercent` is below `shortPercent`, and with `long-reserve` when a
|
|
97
|
+
present long window is below `longPercent`. An absent long window is
|
|
98
|
+
non-constraining. A remaining percent equal to the reserve passes.
|
|
99
|
+
3. Survivors are scored by their most constrained window:
|
|
100
|
+
`min(shortRemaining, longRemaining)` with an absent long window counting as
|
|
101
|
+
100.
|
|
102
|
+
4. Ordering is total and deterministic: score descending, then long-window
|
|
103
|
+
remaining descending, then provider name ascending (`codex` before `kimi`).
|
|
104
|
+
5. No survivor yields `selection: null` and exit code `2`.
|
|
105
|
+
|
|
106
|
+
## Error taxonomy
|
|
107
|
+
|
|
108
|
+
Per provider, independently of the other:
|
|
109
|
+
|
|
110
|
+
- `ok` — a normalized snapshot was produced.
|
|
111
|
+
- `unavailable` — transport failure, timeout, spawn failure, early child exit,
|
|
112
|
+
5xx, or OAuth refresh unavailability.
|
|
113
|
+
- `auth` — 401/403, logged-out (tombstone) Kimi credentials, missing Kimi
|
|
114
|
+
credential configuration, or a Codex server-side authentication rejection.
|
|
115
|
+
- `protocol` — malformed JSON, wrong content type, JSON-RPC error, out-of-order
|
|
116
|
+
exchange, or any response shape drift.
|
|
117
|
+
|
|
118
|
+
## Security boundary
|
|
119
|
+
|
|
120
|
+
- Tokens and raw credentials are used in-process only and are never printed,
|
|
121
|
+
persisted, or embedded in errors. The Kimi bearer is read through
|
|
122
|
+
`openKimiOAuthStore`'s non-mutating `getCurrentAccessToken` (returned only
|
|
123
|
+
while valid beyond a fixed 30-second safety margin): the probe never
|
|
124
|
+
refreshes, persists, renames, or tombstones credentials, and a credential
|
|
125
|
+
inside the margin classifies as `unavailable` with a fixed message. Probe
|
|
126
|
+
failures use the store's fixed-message discipline with no causes. Codex
|
|
127
|
+
server error detail (which can carry account material) is inspected for
|
|
128
|
+
classification only and never propagated.
|
|
129
|
+
- The report schema is closed and carries only provider names, statuses, fixed
|
|
130
|
+
messages, and window numbers — no account IDs, plan names, emails, tokens, or
|
|
131
|
+
raw response bodies. Committed test fixtures contain sanitized structure with
|
|
132
|
+
representative numeric values only.
|
|
133
|
+
- The Codex child is always killed (success, error, and timeout), its stderr is
|
|
134
|
+
consumed but never forwarded, and the exchange is bounded. Kimi requests use
|
|
135
|
+
`redirect: "error"`, strict JSON content-type, and a capped body read.
|
|
136
|
+
- The command introduces no new environment variables and no persistence, so
|
|
137
|
+
the existing evidence redaction sweep is unaffected.
|
|
138
|
+
|
|
139
|
+
## Ledger contract
|
|
140
|
+
|
|
141
|
+
The external worker ledger calls `threadwire capacity`, verifies the report,
|
|
142
|
+
and pins the selected provider, the model (from its own allowlists — capacity
|
|
143
|
+
is account-level and Threadwire performs no model selection), and the native
|
|
144
|
+
session for the task. Resumed sessions and correction runs reuse that pin and
|
|
145
|
+
must not re-run admission; `threadwire capacity` takes no session arguments and
|
|
146
|
+
`threadwire run` explicit-provider and `--resume-session` behavior is
|
|
147
|
+
unchanged.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Development container
|
|
2
|
+
|
|
3
|
+
`compose.dev.yaml` provides the persistent `app` service for Threadwire
|
|
4
|
+
development. It is separate from the production runtime definitions. The image
|
|
5
|
+
installs Codex CLI, Claude Code CLI, the official Kimi Code CLI, OpenCode,
|
|
6
|
+
`oc-codex-multi-auth`, `@ai-sdk/openai-compatible`, and `@ai-sdk/xai` during the
|
|
7
|
+
Docker build. Runtime setup never downloads a plugin through `npx`.
|
|
8
|
+
|
|
9
|
+
## Source paths
|
|
10
|
+
|
|
11
|
+
All source paths in `.env` are required and have no checked-in host defaults:
|
|
12
|
+
|
|
13
|
+
- `DEV_HOME_PATH` is the complete dedicated development home. It remains the
|
|
14
|
+
owner of OpenCode database, session, log, and cache state.
|
|
15
|
+
- `GH_CONFIG_SOURCE_PATH` is the GitHub CLI config directory and mounts
|
|
16
|
+
read-only.
|
|
17
|
+
- `CODEX_AUTH_SOURCE_PATH` is native Codex's `auth.json` and mounts read-write
|
|
18
|
+
so OAuth refreshes persist to its canonical owner.
|
|
19
|
+
- `OPENCODE_AUTH_SOURCE_PATH` is OpenCode's native `auth.json`. DeepSeek, xAI,
|
|
20
|
+
and Moonshot AI keys are entered with OpenCode's auth flow and persist
|
|
21
|
+
through this read-write file mount.
|
|
22
|
+
- `OPENCODE_CODEX_STATE_SOURCE_PATH` is the plugin's dedicated state directory,
|
|
23
|
+
containing the OpenAI OAuth file and global account pool. It mounts
|
|
24
|
+
read-write as a directory because pool updates use atomic sibling-file
|
|
25
|
+
replacement.
|
|
26
|
+
- `AGENT_CONTEXT_PATH` is the immutable agent-context snapshot and mounts
|
|
27
|
+
read-only.
|
|
28
|
+
|
|
29
|
+
Hermes owns the canonical credential files. Do not copy them into the checkout,
|
|
30
|
+
the image, or project-keyed task-home files. The app sets
|
|
31
|
+
`CODEX_AUTH_PER_PROJECT_ACCOUNTS=0`, so `oc-codex-multi-auth` uses its one
|
|
32
|
+
global mounted account pool rather than creating per-project credential copies.
|
|
33
|
+
|
|
34
|
+
## OpenCode catalog
|
|
35
|
+
|
|
36
|
+
The image-owned `/usr/local/share/threadwire-dev/opencode.json` is root-owned,
|
|
37
|
+
read-only, secret-free, and selected with OpenCode's supported
|
|
38
|
+
`OPENCODE_CONFIG` mechanism. It exposes:
|
|
39
|
+
|
|
40
|
+
- OpenAI OAuth through `oc-codex-multi-auth`: `gpt-5.6-sol`,
|
|
41
|
+
`gpt-5.6-terra`, and `gpt-5.6-luna`.
|
|
42
|
+
- DeepSeek through `@ai-sdk/openai-compatible` at
|
|
43
|
+
`https://api.deepseek.com/v1`: `deepseek-v4-pro` and
|
|
44
|
+
`deepseek-v4-flash`.
|
|
45
|
+
- xAI through `@ai-sdk/xai`: `grok-4.5`, `grok-build-0.1`, and
|
|
46
|
+
`grok-code-fast-1`.
|
|
47
|
+
- Kimi K3 through OpenCode's built-in `moonshotai` provider: `kimi-k3` with
|
|
48
|
+
its 1,048,576-token context, 131,072-token output limit, default `max`
|
|
49
|
+
reasoning effort, and explicit `low`, `high`, and `max` variants.
|
|
50
|
+
|
|
51
|
+
Provider keys never belong in Compose environment values or the image config.
|
|
52
|
+
Use `opencode auth login` to update the mounted OpenCode auth store, including
|
|
53
|
+
the canonical Moonshot credential under provider ID `moonshotai`. Direct
|
|
54
|
+
OpenCode invocation is limited to auth, `opencode models <provider>` discovery,
|
|
55
|
+
and bounded smoke checks. Threadwire remains the worker execution and streaming
|
|
56
|
+
boundary for development tasks.
|
|
57
|
+
|
|
58
|
+
## Startup
|
|
59
|
+
|
|
60
|
+
Run the profile-free initializer before Docker creates the app container:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
docker compose -f compose.dev.yaml up --build home-init
|
|
64
|
+
docker compose -f compose.dev.yaml --profile app up --detach
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`home-init` runs as `1000:1000`, mounts only `DEV_HOME_PATH`, and precreates
|
|
68
|
+
every nested directory and file mount target. It receives no auth or context
|
|
69
|
+
mounts.
|
|
70
|
+
|
|
71
|
+
Run project commands in the persistent app service:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
docker compose -f compose.dev.yaml exec app npm ci
|
|
75
|
+
docker compose -f compose.dev.yaml exec app node --test test/container-dev.test.js
|
|
76
|
+
docker compose -f compose.dev.yaml exec app npm run lint
|
|
77
|
+
```
|
package/package.json
CHANGED
|
@@ -24,9 +24,11 @@ const EXPECTED_FILES = [
|
|
|
24
24
|
"bin/isolated-runtime.js",
|
|
25
25
|
"bin/kimi-model-broker.js",
|
|
26
26
|
"bin/model-broker.js",
|
|
27
|
+
"docs/capacity-admission.md",
|
|
27
28
|
"docs/card-10520-plan.md",
|
|
28
29
|
"docs/container-runtime.md",
|
|
29
30
|
"docs/delegated-result-protocol.md",
|
|
31
|
+
"docs/development-container.md",
|
|
30
32
|
"docs/evidence-artifacts.md",
|
|
31
33
|
"docs/isolated-provider-runtime.md",
|
|
32
34
|
"package.json",
|
|
@@ -40,6 +42,7 @@ const EXPECTED_FILES = [
|
|
|
40
42
|
"scripts/verify-package.js",
|
|
41
43
|
"src/absolute-deadline.js",
|
|
42
44
|
"src/activity-log.js",
|
|
45
|
+
"src/activity-log-status.js",
|
|
43
46
|
"src/normalized-output.js",
|
|
44
47
|
"src/cli.js",
|
|
45
48
|
"src/context-budget-metrics.js",
|
|
@@ -50,6 +53,7 @@ const EXPECTED_FILES = [
|
|
|
50
53
|
"src/isolated-runtime.js",
|
|
51
54
|
"src/isolated-state.js",
|
|
52
55
|
"src/isolated-worker.js",
|
|
56
|
+
"src/jsonl-record-spool.js",
|
|
53
57
|
"src/kimi-model-broker-policy.js",
|
|
54
58
|
"src/kimi-model-broker.js",
|
|
55
59
|
"src/kimi-oauth-store.js",
|
|
@@ -66,6 +70,9 @@ const EXPECTED_FILES = [
|
|
|
66
70
|
"src/providers/index.js",
|
|
67
71
|
"src/providers/kimi.js",
|
|
68
72
|
"src/providers/opencode.js",
|
|
73
|
+
"src/provider-capacity-codex.js",
|
|
74
|
+
"src/provider-capacity-kimi.js",
|
|
75
|
+
"src/provider-capacity.js",
|
|
69
76
|
"src/relay.js",
|
|
70
77
|
"src/relay-write.js",
|
|
71
78
|
"src/run-worker.js",
|
|
@@ -123,6 +130,7 @@ async function main() {
|
|
|
123
130
|
"--help"
|
|
124
131
|
], {env: npmEnvironment, maxBuffer: 10 * 1024 * 1024})
|
|
125
132
|
assert.match(helpOutput, /^Usage: threadwire run /u)
|
|
133
|
+
assert.match(helpOutput, /threadwire capacity /u)
|
|
126
134
|
|
|
127
135
|
await assert.rejects(
|
|
128
136
|
execFileAsync("npm", [
|
package/src/absolute-deadline.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
/* eslint-disable jsdoc/require-jsdoc */
|
|
3
3
|
|
|
4
4
|
export class AbsoluteDeadline {
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* @param {number | undefined} expiresAt
|
|
7
|
+
* @param {{signal?: AbortSignal, request?: import("node:http").IncomingMessage, response?: import("node:http").ServerResponse, now?: () => number, timeoutMessage?: string, disconnectMessage?: string, destroyRequestOnAbort?: boolean}} [options]
|
|
8
|
+
*/
|
|
9
|
+
constructor(expiresAt, {signal, request, response, now = Date.now, timeoutMessage = "Operation timeout", disconnectMessage = "Caller disconnected", destroyRequestOnAbort = true} = {}) {
|
|
6
10
|
if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt <= now())) throw new Error(timeoutMessage)
|
|
7
11
|
this.expiresAt = expiresAt
|
|
8
12
|
this.now = now
|
|
@@ -27,13 +31,12 @@ export class AbsoluteDeadline {
|
|
|
27
31
|
response?.once?.("close", this.abortResponse)
|
|
28
32
|
this.socket?.once?.("close", this.abortSocket)
|
|
29
33
|
this.timer = expiresAt === undefined ? undefined : setTimeout(() => this.controller.abort(new Error(timeoutMessage)), Math.max(1, expiresAt - now()))
|
|
30
|
-
this.signal.addEventListener("abort", () => request?.destroy?.(this.signal.reason), {once: true})
|
|
34
|
+
if (destroyRequestOnAbort) this.signal.addEventListener("abort", () => request?.destroy?.(this.signal.reason), {once: true})
|
|
31
35
|
if (request?.aborted || (response?.destroyed && !response.writableEnded)) this.abortRequest()
|
|
32
36
|
}
|
|
33
37
|
remaining() {
|
|
34
38
|
this.throwIfAborted()
|
|
35
39
|
if (this.expiresAt === undefined) return undefined
|
|
36
|
-
if (this.expiresAt === undefined) return undefined
|
|
37
40
|
const remaining = this.expiresAt - this.now()
|
|
38
41
|
if (remaining <= 0) {
|
|
39
42
|
this.controller.abort(new Error(this.timeoutMessage))
|