threadwire 0.1.20 → 0.1.22

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,124 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Fix Node 24 release-prepublish timeouts in `run-worker` process-tree cleanup.
6
+ The dedicated `npm-release` service now runs with Docker's `init: true` so
7
+ terminated descendants are reaped instead of becoming zombies that keep the
8
+ attempt-owned process group alive. Harden the default process-group probe in
9
+ `src/run-worker.js` to treat `EPERM` as "still alive" and only `ESRCH`/`EINVAL`
10
+ as "gone", preventing false settlement when signaling is denied. Add a focused
11
+ regression test that mocks `process.kill` returning `EPERM`.
12
+
13
+ - Allow the Kimi model broker to bind port `0` for both the admin and worker
14
+ listeners and expose the OS-assigned actual ports in the returned config.
15
+ Update `test/kimi-model-broker.test.js` to use this deterministic allocation
16
+ instead of a `freePort()` helper that closed a socket before the broker
17
+ rebound it, eliminating the EADDRINUSE race when `node --test` runs test
18
+ files concurrently. Add a regression test that starts many brokers in
19
+ parallel with port `0` and verifies no duplicate ports are assigned.
20
+ Production defaults (8791/8792) and explicit fixed-port configuration are
21
+ unchanged.
22
+
23
+ - Mount the existing read-only GitHub CLI config into the dedicated
24
+ `npm-release` service at `${DEV_CONTAINER_HOME}/.config/gh` and set
25
+ `GH_CONFIG_DIR`. The `docker/npm-release-entrypoint.sh` entrypoint now runs
26
+ `gh auth setup-git` before forwarding to `npm run release:patch`, initializing
27
+ GitHub CLI's HTTPS credential helper in the writable HOME git config from the
28
+ read-only GH config so the fresh origin/master preflight (`git fetch origin`)
29
+ can authenticate. Fail closed if setup-git fails. The npm publish token remains
30
+ the only release-only credential; no provider runtime, agent context, or
31
+ provider credentials are added to `npm-release`, and `home-init` still
32
+ receives no auth mounts. Update `AGENTS.md`,
33
+ `docs/development-container.md`, and contract tests to match.
34
+
35
+ - Align npm release auth with the fleet standard. Rename
36
+ `NPM_CONFIG_SOURCE_PATH` to `NPM_AUTH_SOURCE_PATH`; the canonical credential
37
+ is `${NPM_AUTH_SOURCE_PATH}/npmrc` and is mounted read-only into the
38
+ dedicated `npm-release` service at `${DEV_CONTAINER_HOME}/.npmrc`. The
39
+ persistent `app` service and profile-free `home-init` never receive the npm
40
+ auth source. Update `.env.example`, `AGENTS.md`,
41
+ `docs/development-container.md`, and tests to match.
42
+
43
+ - Add `release-patch@1.0.3` as a devDependency and a `release:patch` package
44
+ script. `npm run release:patch` is now the only normal publishing path; it
45
+ owns version bump, changelog/tag handling, publish verification, and the
46
+ final `npm publish` step. The dedicated `npm-release` service in
47
+ `compose.dev.yaml` now runs `npm run release:patch` instead of invoking
48
+ `npm publish` directly, and `AGENTS.md` and `docs/development-container.md`
49
+ document the new contract.
50
+
51
+ - Add a dedicated, profile-gated `npm-release` service to `compose.dev.yaml`
52
+ for npm releases. The service mounts the server-held publish token read-only
53
+ at `${DEV_CONTAINER_HOME}/.npmrc`, runs as UID/GID 1000 on the canonical
54
+ checkout, and executes the repository-owned `npm publish` procedure. The
55
+ persistent `app` service no longer mounts the npm auth config, so the token
56
+ is never exposed to workers, scripts, or the long-running development
57
+ container. A new `docker/npm-release-entrypoint.sh` image entrypoint simply
58
+ forwards to the release command without loading auth, agent context, or
59
+ provider discovery links. `AGENTS.md`, `docs/development-container.md`, and
60
+ `.env.example` document the new release-only workflow.
61
+
62
+ - Add a per-run SQLite evidence index using the built-in `node:sqlite` module.
63
+ Every evidence-creating run now writes a private, pending-until-finalized
64
+ `.index` database alongside the canonical raw and result artifacts. The index
65
+ stores a versioned manifest and an ordered event ledger of bounded fixed kinds
66
+ (prompt, provider stdout/stderr, assistant, tool, lifecycle, diagnostic,
67
+ context metrics, provider result) with timestamps, order, and artifact
68
+ references; it does not duplicate arbitrarily large raw chunks and applies the
69
+ same redaction and retention boundaries as the existing `read` surface. Indexes
70
+ use `DELETE` journal mode and atomic rename so crashes leave no published
71
+ database; leftover pending/journal files are reconciled on startup and removed
72
+ with their run when the last artifact is cleaned up.
73
+ - Add `threadwire evidence inspect --handle evidence_<bearer>` with fixed
74
+ selectors only: `--summary`, `--events` (with optional `--kind`, `--after`,
75
+ `--before`, and `--limit`), and `--failures` (with optional `--after` and
76
+ `--limit`). The database path is never exposed, results are byte/event bounded,
77
+ and authorization, expiry, and redaction remain consistent with `threadwire evidence read`.
78
+ `--failures` filters diagnostic errors and finished tool events inside SQLite
79
+ before applying the limit, supports deterministic `--after` pagination, and
80
+ reports `truncated: true` only when additional matching failures exist.
81
+ Telegram ingress creates and finalizes the same per-run index for its evidence runs.
82
+ - Account per-run `.index` bytes against the existing `maxRunBytes` and
83
+ `maxStoreBytes` quotas with bounded per-event reservations and final-size
84
+ settlement. Index growth that would exceed a run or store limit is rejected
85
+ safely, the pending database is never published, and bytes are released when a
86
+ run's last artifact is cleaned up, revoked, or aborted before finalization;
87
+ restart reconciliation reserves bytes for existing ready indexes.
88
+ - Apply the artifact redaction policy to index event metadata: secrets
89
+ configured for `evidence read` are replaced with `[REDACTED]` in persisted and
90
+ returned index summaries, lifecycle/diagnostic strings, and tool detail before
91
+ SQLite storage or inspection output; raw evidence remains the canonical
92
+ unindexed source.
93
+ - Upgrade the checked-in Node runtime target to the Node 24 LTS (Krypton)
94
+ release `v24.19.0`. Update `Dockerfile`, `docker/Dockerfile.dev`,
95
+ `src/isolated-runtime.js`, `docker/test/isolated-runtime-e2e.sh`, tests,
96
+ `AGENTS.md`, `README.md`, and `docs/container-runtime.md`; update
97
+ `@types/node` to `24.13.3` and `engines.node` to `>=24` in `package.json`.
98
+ Archive SHA-256 values for the development image are verified against the
99
+ official Node SHASUMS256.txt.
100
+
101
+ - Standardize the development container on Hermes' shared provider runtime with
102
+ UID/GID-1000-only Codex, OpenCode, and Kimi bootstrap links.
103
+ - Keep the OpenCode data directory (`~/.local/share/opencode`) local to each
104
+ `DEV_HOME_PATH`; share only the `auth.json` file from the provider runtime and
105
+ continue sharing the dedicated `.opencode` plugin-state directory. Update the
106
+ home initializer, entrypoint verification, tests, and guidance to match.
107
+
108
+ - Make the checked-in development container portable and secure for direct
109
+ native Kimi: runtime paths (`DEV_CONTAINER_HOME`, `KIMI_CODE_HOME`) are
110
+ explicit, portable, and absolute instead of hard-coded `/home/dev`; the
111
+ immutable agent context mounts from the named external Docker volume
112
+ `AGENT_CONTEXT_VOLUME` rather than a host path; agent-context links for Kimi
113
+ go to the configured `KIMI_CODE_HOME`, which may differ from the dev home;
114
+ direct Kimi auth mounts from the named external volume `KIMI_AUTH_VOLUME`
115
+ only as narrow subpaths under the configurable `KIMI_AUTH_VOLUME_SUBPATH`
116
+ (default `.kimi-code`; `credentials` writable, `config.toml` and `device_id`
117
+ read-only) in the normal `app` service; a dedicated profiled `kimi-auth`
118
+ service bypasses the normal dev entrypoint, mounts the full auth volume root
119
+ writable, bootstraps the configured subpath, and runs `kimi login` for
120
+ interactive authentication/refresh. The Kimi Code CLI in the dev image is
121
+ pinned to `0.34.0` with the same integrity verification used in production.
122
+ Production runtime files remain unchanged.
5
123
  - Resolve native Kimi through the normal `PATH` (`kimi`) instead of requiring
6
124
  `/usr/local/bin/kimi`; `THREADWIRE_KIMI_BIN` remains the explicit override.
7
125
  - Resolve native Codex through `THREADWIRE_CODEX_BIN` or normal `PATH` lookup,
package/README.md CHANGED
@@ -24,7 +24,7 @@ See [Isolated provider runtime](docs/isolated-provider-runtime.md).
24
24
 
25
25
  ## Run with npx
26
26
 
27
- Threadwire requires Node.js 22 or newer. Run the published CLI without a global
27
+ Threadwire requires Node.js 24 or newer. Run the published CLI without a global
28
28
  installation:
29
29
 
30
30
  ```sh
@@ -122,7 +122,9 @@ Provider stdout is decoded as JSONL with a finite 1 MiB UTF-8 byte limit for eac
122
122
 
123
123
  For normal `threadwire run` execution, Threadwire stdout is the parent-model admission plane. It emits exactly one versioned terminal `delegated_result` JSON object and no progress records. The envelope contains a bounded, credential-redacted, control-normalized final conclusion when one was produced, structured terminal state and exit code, and the exact bounded provider session handle when available. Its closed schema can also represent concise blocker/decision requests, opaque artifact handles, commit/URL references, and a concise validation summary; current provider adapters do not infer these optional fields from prose. Unknown, nested, wrongly typed, cardinality-invalid, or oversized admission candidates fail closed. See [Delegated-result protocol](docs/delegated-result-protocol.md) for the complete schema, limits, and caller obligations.
124
124
 
125
- Scoped evidence artifacts are separate from both files above. Set the trusted operator setting `THREADWIRE_EVIDENCE_ROOT` for standalone runs; Telegram ingress uses its dedicated evidence volume automatically. CLI and ingress runs reserve separate normalized provider-result and raw prompt/provider-stream artifacts; CLI raw evidence also records final context metrics. A raw handle enters a normalized result only after the raw artifact is durable; if raw metrics append or finalization fails, the error remains visible while the completed provider result retains only its independently durable result handle. The result artifact carries a stable delivery identity and authenticated state, and ingress sends that result handle through the same bounded notice queue. 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, delivery states, quotas, retention, recovery, authorization, redaction, privacy, and cleanup behavior.
125
+ Scoped evidence artifacts are separate from both files above. Set the trusted operator setting `THREADWIRE_EVIDENCE_ROOT` for standalone runs; Telegram ingress uses its dedicated evidence volume automatically. CLI and ingress runs reserve separate normalized provider-result and raw prompt/provider-stream artifacts; CLI raw evidence also records final context metrics. A raw handle enters a normalized result only after the raw artifact is durable; if raw metrics append or finalization fails, the error remains visible while the completed provider result retains only its independently durable result handle. The result artifact carries a stable delivery identity and authenticated state, and ingress sends that result handle through the same bounded notice queue. Retrieve later with `threadwire evidence read`, inspect the per-run SQLite index
126
+ with `threadwire evidence inspect`, or use the authenticated `/evidence` Telegram
127
+ command with an explicit bounded selector. See [Evidence artifacts](docs/evidence-artifacts.md) for permissions, delivery states, quotas, retention, recovery, authorization, redaction, privacy, cleanup, and the inspect fixed-selector contract.
126
128
 
127
129
  `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.
128
130
 
@@ -139,7 +141,7 @@ The delivery queue has finite defaults of 100 pending notices and 256 KiB of pen
139
141
  ## Development
140
142
 
141
143
  The checked-in development container is the `app` service in `compose.dev.yaml`
142
- (Ubuntu 26.04 pinned by digest, non-root `1000:1000`, Node 22 LTS, npm-latest
144
+ (Ubuntu 26.04 pinned by digest, non-root `1000:1000`, Node 24 LTS, npm-latest
143
145
  coding CLI baseline). Setup, the read-only GitHub CLI and agent-context mount
144
146
  contracts, and container commands are documented in [AGENTS.md](AGENTS.md).
145
147
 
@@ -19,7 +19,7 @@ force unless `THREADWIRE_MAX_CONCURRENT_WORKERS` is explicitly lowered or raised
19
19
 
20
20
  ## Build and configure
21
21
 
22
- Use Node image `node:22.17.0-bookworm-slim`. The build derives seed identity
22
+ Use Node image `node:24.19.0-bookworm-slim`. The build derives seed identity
23
23
  inside the image from deterministic paths, modes, file contents, symlink targets, and the generated `node_modules`; only `.git` and the identity output itself are excluded. No caller revision argument is accepted. The exact base-image tags are recorded but are not claimed
24
24
  immutable. Replace them with registry-verified manifest digests during a release
25
25
  only after independently verifying those digests; no digest is invented here.
@@ -211,7 +211,7 @@ relay also uses UID/GID 10003 with no mounts and no credential environment; the
211
211
  worker uses
212
212
  UID/GID 10002 with only the selected worktree and provider-bound state; the
213
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
214
+ Node `24.19.0` and exactly `@moonshot-ai/kimi-code@0.34.0` with the checked npm
215
215
  integrity and archive SHA-512 in the Dockerfile.
216
216
 
217
217
  Enable the checked-in override with `--profile kimi` only after the OAuth/model
@@ -6,30 +6,74 @@ installs Codex CLI, Claude Code CLI, the official Kimi Code CLI, OpenCode,
6
6
  `oc-codex-multi-auth`, `@ai-sdk/openai-compatible`, and `@ai-sdk/xai` during the
7
7
  Docker build. Runtime setup never downloads a plugin through `npx`.
8
8
 
9
- ## Source paths
9
+ ## Source paths and volumes
10
10
 
11
11
  All source paths in `.env` are required and have no checked-in host defaults:
12
12
 
13
- - `DEV_HOME_PATH` is the complete dedicated development home. It remains the
14
- owner of OpenCode database, session, log, and cache state.
13
+ - `DEV_HOME_PATH` is the complete dedicated development home on the host. It
14
+ remains the owner of OpenCode database, session, log, and cache state.
15
+ - `DEV_CONTAINER_HOME` is the absolute container path where `DEV_HOME_PATH` is
16
+ bind-mounted writable. The Threadwire checkout is expected at
17
+ `${DEV_CONTAINER_HOME}/threadwire`.
18
+ - Kimi Code uses the standard `${DEV_CONTAINER_HOME}/.kimi-code` home.
15
19
  - `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.
20
+ read-only at `${DEV_CONTAINER_HOME}/.config/gh`.
21
+ - `NPM_AUTH_SOURCE_PATH` is the server-held npm auth directory. The canonical
22
+ credential is `${NPM_AUTH_SOURCE_PATH}/npmrc`; it is mounted read-only into
23
+ the dedicated `npm-release` service at `${DEV_CONTAINER_HOME}/.npmrc` so
24
+ `npm publish` can authenticate without copying tokens into the checkout,
25
+ image, or writable container state. The persistent `app` service never
26
+ receives this mount.
27
+ - `PROVIDER_RUNTIME_SOURCE_PATH` is Hermes' canonical shared provider runtime,
28
+ mounted at `/opt/hermes-dind-shared/auth/provider-runtime`.
29
+ - `AGENT_CONTEXT_VOLUME` is the name of the external Docker volume that holds
30
+ the immutable agent-context snapshot. It mounts read-only at
31
+ `/opt/agent-context`.
32
+
33
+ Hermes owns the canonical provider runtime. The UID/GID-1000 initializer links
34
+ `.codex`, `.opencode`, and `.kimi-code` from the development home to matching
35
+ directories under the mounted runtime. The OpenCode data directory
36
+ `~/.local/share/opencode` remains local within `DEV_HOME_PATH`; only its
37
+ `auth.json` file is linked to the shared runtime. It fails closed on
38
+ conflicting paths and never copies credentials or changes host ownership. The
39
+ app sets `CODEX_AUTH_PER_PROJECT_ACCOUNTS=0`, so `oc-codex-multi-auth` uses its
40
+ one global mounted account pool rather than creating per-project credential
41
+ copies.
42
+
43
+ ## Provider runtime bootstrap
44
+
45
+ The profile-free `home-init` service creates the four provider-home symlinks as
46
+ UID/GID 1000 before Docker creates the profiled app container. The app mounts
47
+ the shared runtime read-write so provider login and token refresh can update its
48
+ canonical owner. No development service starts as root or invokes `chown`.
49
+
50
+ ## npm releases
51
+
52
+ Publish releases from the dedicated `npm-release` service. This profile-gated,
53
+ short-lived container is the only place the server-held npm publish token is
54
+ mounted; the persistent `app` service never sees it.
55
+
56
+ ```sh
57
+ docker compose -f compose.dev.yaml --profile npm-release run --rm npm-release
58
+ ```
59
+
60
+ The service runs `npm run release:patch` as UID/GID `1000` in the canonical
61
+ checkout at `${DEV_CONTAINER_HOME}/threadwire`, with
62
+ `${NPM_AUTH_SOURCE_PATH}/npmrc` bound read-only at
63
+ `${DEV_CONTAINER_HOME}/.npmrc`. It also binds the existing GitHub CLI config
64
+ read-only at `${DEV_CONTAINER_HOME}/.config/gh` and sets
65
+ `GH_CONFIG_DIR: ${DEV_CONTAINER_HOME}/.config/gh`. Before forwarding to
66
+ `npm run release:patch`, the entrypoint runs `gh auth setup-git`, initializing
67
+ GitHub CLI's HTTPS credential helper in the writable HOME git config from the
68
+ read-only GH config so the fresh origin/master preflight (`git fetch origin`)
69
+ can authenticate; it fails closed if setup-git fails. No GitHub token is copied
70
+ and the npm publish token remains release-only. It mounts only the dev home,
71
+ the npmrc file, and the read-only GitHub config; it does not mount agent
72
+ context, Codex, OpenCode, or Kimi credentials. `npm run release:patch` is the
73
+ only normal publishing path and owns the version bump, changelog/tag handling,
74
+ and publish verification; do not run `npm publish` directly. The repository's
75
+ `prepublishOnly` script still runs `npm run all-checks` and `npm run
76
+ verify:package` as the final publish gate.
33
77
 
34
78
  ## OpenCode catalog
35
79
 
@@ -123,6 +123,53 @@ and their pointer values become `[REDACTED]`; responses state whether they were
123
123
  redacted or truncated. This finite policy is defense in depth, not
124
124
  arbitrary-secret detection.
125
125
 
126
+ ## Per-run SQLite index
127
+
128
+ Every run that creates evidence also builds a private per-run SQLite index using
129
+ the built-in `node:sqlite` module. The index is created in a pending state and
130
+ atomically renamed to a ready `.index` file only after the run's result artifact
131
+ is finalized, so a crash or finalization failure leaves no published database.
132
+ It uses `DELETE` journal mode: transient SQLite sidecars are removed on commit
133
+ and any leftover journal files are reconciled on startup. The index is stored
134
+ alongside the canonical raw and result artifacts and is removed when the run's
135
+ last artifact is cleaned up or revoked.
136
+
137
+ The index stores a versioned manifest (provider, destination, timestamps,
138
+ terminal state) and an ordered event ledger of bounded, fixed kinds: `prompt`,
139
+ `provider_stdout`, `provider_stderr`, `assistant`, `tool`, `lifecycle`,
140
+ `diagnostic`, `context_metrics`, and `provider_result`. It records metadata,
141
+ timestamps, order, and artifact references rather than duplicating arbitrarily
142
+ large raw chunks; the canonical raw evidence remains the source of truth.
143
+ Sensitive material is bounded or omitted consistently with the `read` surface.
144
+
145
+ Inspect the index through the same opaque bearer handle with fixed selectors
146
+ only; the database path is never exposed.
147
+
148
+ ```console
149
+ threadwire evidence inspect --handle evidence_<bearer> --summary
150
+ threadwire evidence inspect --handle evidence_<bearer> --events --kind tool --limit 50
151
+ threadwire evidence inspect --handle evidence_<bearer> --failures --limit 20
152
+ ```
153
+
154
+ `--summary` returns manifest, totals, and per-kind counts. `--events` supports an
155
+ optional `--kind` filter plus `--after`/`--before` order bounds and `--limit`
156
+ (up to 1000). `--failures` returns diagnostic errors and finished tool events,
157
+ supports `--after` pagination, and reports `truncated: true` only when further
158
+ matching failures exist after the returned page. Filtering happens inside SQLite
159
+ before the result limit, so benign rows never hide later failures.
160
+
161
+ The `.index` file size is counted against the same `maxRunBytes` and
162
+ `maxStoreBytes` quotas as other artifacts; the store reserves a bounded per-event
163
+ allowance during ingestion and settles against the final file size. Index growth
164
+ is rejected safely rather than allowed to exhaust the volume, and the index is
165
+ removed and its quota released when the run's last artifact is cleaned up,
166
+ revoked, or aborted before finalization. All index event metadata is redacted
167
+ with the same artifact redaction policy as `evidence read` before persistence
168
+ and before return, so provider secrets do not leak into the queryable index.
169
+
170
+ All results are byte/event bounded and expire with the artifact. A wrong scope,
171
+ bad selector, missing capability, or expired index reports generic unavailability.
172
+
126
173
  ## Operations
127
174
 
128
175
  Run `cleanup()` from maintenance and use scoped removal for revocation. Cleanup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -30,7 +30,7 @@
30
30
  "threadwire-telegram-webhook": "bin/telegram-webhook.js"
31
31
  },
32
32
  "engines": {
33
- "node": ">=22"
33
+ "node": ">=24"
34
34
  },
35
35
  "repository": {
36
36
  "type": "git",
@@ -55,16 +55,18 @@
55
55
  "test:container-e2e": "sh docker/test/production-e2e.sh",
56
56
  "test:isolated-runtime-e2e": "sh docker/test/isolated-runtime-e2e.sh",
57
57
  "typecheck": "tsc --noEmit",
58
+ "release:patch": "release-patch",
58
59
  "verify:package": "node scripts/verify-package.js",
59
60
  "test:kimi-isolated-runtime-e2e": "sh docker/test/kimi-isolated-runtime-e2e.sh"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@eslint/js": "10.0.1",
63
- "@types/node": "22.15.3",
64
+ "@types/node": "24.13.3",
64
65
  "eslint": "10.0.1",
65
66
  "eslint-plugin-jsdoc": "63.0.0",
66
67
  "fallow": "3.2.0",
67
68
  "globals": "17.0.0",
69
+ "release-patch": "1.0.3",
68
70
  "typescript": "6.0.2"
69
71
  },
70
72
  "license": "AGPL-3.0-only"
@@ -48,6 +48,7 @@ const EXPECTED_FILES = [
48
48
  "src/context-budget-metrics.js",
49
49
  "src/delegated-result-admission.js",
50
50
  "src/docker-api.js",
51
+ "src/evidence-index.js",
51
52
  "src/evidence-store.js",
52
53
  "src/isolated-runtime-client.js",
53
54
  "src/isolated-runtime.js",
@@ -70,6 +71,7 @@ const EXPECTED_FILES = [
70
71
  "src/providers/index.js",
71
72
  "src/providers/kimi.js",
72
73
  "src/providers/opencode.js",
74
+ "src/redaction.js",
73
75
  "src/provider-capacity-codex.js",
74
76
  "src/provider-capacity-kimi.js",
75
77
  "src/provider-capacity.js",
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@ import {buildProviderEnvironment, collectEvidenceRedactions, parseTelegramReques
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 {workerEventToIndexEvent} from "./evidence-index.js"
18
19
  import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
19
20
  import {validateRelayWriteProviderArguments} from "./relay-write.js"
20
21
  import {abortable} from "./absolute-deadline.js"
@@ -40,6 +41,8 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
40
41
  [-- <provider arguments...>]
41
42
  threadwire evidence read --handle <opaque-handle>
42
43
  (--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)
44
+ threadwire evidence inspect --handle <opaque-handle>
45
+ (--summary | --events [--kind <kind>] [--after <order>] [--before <order>] [--limit <positive-integer>] | --failures [--after <order>] [--limit <positive-integer>])
43
46
  threadwire capacity [--provider <codex|kimi>]...
44
47
  [--short-reserve-percent <0-100>] [--long-reserve-percent <0-100>]
45
48
  [--timeout-ms <positive-integer>]
@@ -47,7 +50,7 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
47
50
  (Emits one closed versioned JSON status document from the activity log.)`
48
51
 
49
52
  /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
50
- /** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
53
+ /** @typedef {{evidenceRead: true, request: unknown} | {evidenceInspect: true, request: unknown}} EvidenceParsedArguments */
51
54
  /** @typedef {{capacity: true, providers: import("./provider-capacity.js").CapacityProvider[], shortReservePercent: number, longReservePercent: number, timeoutMs: number}} CapacityParsedArguments */
52
55
  /** @typedef {{probe: (provider: import("./provider-capacity.js").CapacityProvider, timeoutMs?: number) => Promise<import("./provider-capacity.js").CapacityCandidate>}} CapacityProbeDependency */
53
56
  /**
@@ -128,31 +131,77 @@ export function parseArguments(arguments_) {
128
131
  }
129
132
 
130
133
  /** @param {string[]} arguments_ @returns {EvidenceParsedArguments} */
131
- function parseEvidenceArguments(arguments_) {
132
- if (arguments_[1] !== "read") throw new Error(HELP)
133
- /** @type {Record<string, string>} */
134
+ export function parseEvidenceArguments(arguments_) {
135
+ const subcommand = arguments_[1]
136
+ if (subcommand !== "read" && subcommand !== "inspect") throw new Error(HELP)
137
+ /** @type {Record<string, string | boolean>} */
134
138
  const options = {}
135
- for (let index = 2; index < arguments_.length; index += 2) {
139
+ let index = 2
140
+ while (index < arguments_.length) {
136
141
  const name = arguments_[index]
142
+ if (name === "--summary" || name === "--events" || name === "--failures") {
143
+ if (options[name] !== undefined) throw new Error(`Duplicate evidence option: ${name}`)
144
+ options[name] = true
145
+ index += 1
146
+ continue
147
+ }
137
148
  const value = arguments_[index + 1]
138
- if (!name || !value || !["--handle", "--bytes", "--lines", "--query", "--context-bytes"].includes(name)) throw new Error(HELP)
149
+ if (!name || value === undefined) throw new Error(HELP)
150
+ const readOptions = ["--handle", "--bytes", "--lines", "--query", "--context-bytes"]
151
+ const inspectOptions = ["--handle", "--kind", "--after", "--before", "--limit"]
152
+ const allowed = subcommand === "read" ? readOptions : inspectOptions
153
+ if (!allowed.includes(name)) throw new Error(HELP)
139
154
  if (options[name] !== undefined) throw new Error(`Duplicate evidence option: ${name}`)
140
155
  options[name] = value
156
+ index += 2
141
157
  }
142
- const handle = options["--handle"]
158
+ const handle = /** @type {string | undefined} */ (options["--handle"])
143
159
  if (!handle) throw new Error("--handle is required")
144
- const selectors = ["--bytes", "--lines", "--query"].filter((name) => options[name] !== undefined)
145
- if (selectors.length !== 1) throw new Error("Exactly one bounded evidence selector is required")
146
- let request
147
- if (options["--bytes"]) request = {handle, bytes: rangePair(options["--bytes"], true)}
148
- else if (options["--lines"]) request = {handle, lines: rangePair(options["--lines"], false)}
149
- else {
150
- const context = options["--context-bytes"]
151
- if (!context) throw new Error("--context-bytes is required with --query")
152
- request = {handle, query: {literal: options["--query"], limit: 1, contextBytes: positiveInteger(context, "--context-bytes")}}
160
+ if (subcommand === "read") {
161
+ const selectors = ["--bytes", "--lines", "--query"].filter((name) => options[name] !== undefined)
162
+ if (selectors.length !== 1) throw new Error("Exactly one bounded evidence selector is required")
163
+ let request
164
+ if (options["--bytes"]) request = {handle, bytes: rangePair(/** @type {string} */ (options["--bytes"]), true)}
165
+ else if (options["--lines"]) request = {handle, lines: rangePair(/** @type {string} */ (options["--lines"]), false)}
166
+ else {
167
+ const context = options["--context-bytes"]
168
+ if (!context) throw new Error("--context-bytes is required with --query")
169
+ request = {handle, query: {literal: options["--query"], limit: 1, contextBytes: positiveInteger(/** @type {string} */ (context), "--context-bytes")}}
170
+ }
171
+ if (options["--query"] === undefined && options["--context-bytes"] !== undefined) throw new Error("--context-bytes requires --query")
172
+ return {evidenceRead: true, request}
173
+ }
174
+ const selectors = ["--summary", "--events", "--failures"].filter((name) => options[name] !== undefined)
175
+ if (selectors.length !== 1) throw new Error("Evidence inspect requires exactly one fixed selector")
176
+ const allowed = options["--summary"]
177
+ ? ["--handle", "--summary"]
178
+ : options["--events"]
179
+ ? ["--handle", "--events", "--kind", "--after", "--before", "--limit"]
180
+ : ["--handle", "--failures", "--after", "--limit"]
181
+ const allowedSet = new Set(allowed)
182
+ for (const key of Object.keys(options)) {
183
+ if (!allowedSet.has(key)) throw new Error(`Incompatible evidence inspect flag: ${key}`)
153
184
  }
154
- if (options["--query"] === undefined && options["--context-bytes"] !== undefined) throw new Error("--context-bytes requires --query")
155
- return {evidenceRead: true, request}
185
+ /** @type {import("./evidence-index.js").EvidenceInspectRequest} */
186
+ let request = {handle}
187
+ if (options["--summary"]) {
188
+ request = {...request, summary: true}
189
+ } else if (options["--events"]) {
190
+ /** @type {NonNullable<import("./evidence-index.js").EvidenceInspectRequest["events"]>} */
191
+ const events = {}
192
+ if (options["--kind"]) events.kind = /** @type {string} */ (options["--kind"])
193
+ if (options["--after"]) events.after = positiveInteger(/** @type {string} */ (options["--after"]), "--after")
194
+ if (options["--before"]) events.before = positiveInteger(/** @type {string} */ (options["--before"]), "--before")
195
+ if (options["--limit"]) events.limit = positiveInteger(/** @type {string} */ (options["--limit"]), "--limit")
196
+ request = {...request, events}
197
+ } else {
198
+ /** @type {NonNullable<import("./evidence-index.js").EvidenceInspectRequest["failures"]>} */
199
+ const failures = {}
200
+ if (options["--after"]) failures.after = positiveInteger(/** @type {string} */ (options["--after"]), "--after")
201
+ if (options["--limit"]) failures.limit = positiveInteger(/** @type {string} */ (options["--limit"]), "--limit")
202
+ request = {...request, failures}
203
+ }
204
+ return {evidenceInspect: true, request}
156
205
  }
157
206
 
158
207
  /** @param {string[]} arguments_ @returns {CapacityParsedArguments | {help: true}} */
@@ -292,13 +341,15 @@ export async function main(arguments_, dependencies = {}) {
292
341
  })}\n`)
293
342
  return selection === null ? 2 : 0
294
343
  }
295
- if ("evidenceRead" in parsed) {
344
+ if ("evidenceRead" in parsed || "evidenceInspect" in parsed) {
296
345
  const root = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
297
346
  if (!root) throw new Error("THREADWIRE_EVIDENCE_ROOT is required")
298
347
  if (validateOnly) return 0
299
348
  const store = await EvidenceStore.open({root})
300
349
  try {
301
- const result = await store.readBearer(parsed.request)
350
+ const result = "evidenceRead" in parsed
351
+ ? await store.readBearer(parsed.request)
352
+ : await store.inspectBearer(parsed.request)
302
353
  output.write(`${JSON.stringify(result)}\n`)
303
354
  return 0
304
355
  } finally {
@@ -360,6 +411,11 @@ export async function main(arguments_, dependencies = {}) {
360
411
  let resultEvidenceBytes = 0
361
412
  const deliveryIdentity = randomUUID()
362
413
  const launchDeadline = isolatedPreflight?.deadline
414
+ /** @param {import("./evidence-index.js").EvidenceIndexEvent} event */
415
+ const recordEvidenceEvent = (event) => {
416
+ if (evidenceStore === undefined || evidenceOwner === undefined) return Promise.resolve()
417
+ return evidenceStore.recordEvent(evidenceOwner, event)
418
+ }
363
419
  try {
364
420
  environment = await boundedLaunch(resolveFileBackedSettings(
365
421
  sourceEnvironment,
@@ -381,7 +437,9 @@ export async function main(arguments_, dependencies = {}) {
381
437
  : "file",
382
438
  runId: deliveryIdentity
383
439
  })
440
+ evidenceStore.ensureRunIndex(evidenceOwner, {provider: parsed.provider})
384
441
  const redactions = await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
442
+ evidenceStore.setRunIndexRedactions(evidenceOwner, redactions)
385
443
  evidence = await evidenceStore.createArtifact(evidenceOwner, {
386
444
  contentType: "text/plain; charset=utf-8",
387
445
  redactions
@@ -404,6 +462,12 @@ export async function main(arguments_, dependencies = {}) {
404
462
  if (evidence !== undefined) {
405
463
  const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
406
464
  await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
465
+ await recordEvidenceEvent({
466
+ kind: "prompt",
467
+ artifactId: evidence.metadata.id,
468
+ provider: parsed.provider,
469
+ metadata: {length: Buffer.byteLength(promptEvidence, "utf8")}
470
+ })
407
471
  evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
408
472
  }
409
473
  const providerEnvironment = buildProviderEnvironment(environment, {
@@ -455,6 +519,7 @@ export async function main(arguments_, dependencies = {}) {
455
519
  else if (event.type === "tool") activity?.recordActivity(provider.name, "tool")
456
520
  else if (event.type === "lifecycle") activity?.recordActivity(provider.name, "lifecycle")
457
521
  acceptAdmissionEvent(admission, event)
522
+ await recordEvidenceEvent(workerEventToIndexEvent(event, parsed.provider))
458
523
  if (control !== undefined) {
459
524
  try {
460
525
  await control.accept(event)
@@ -480,13 +545,29 @@ export async function main(arguments_, dependencies = {}) {
480
545
  metrics.recordRawChildChunk("provider_stdout", chunk.length)
481
546
  metrics.recordRejected("provider_stream", chunk.length)
482
547
  if (evidence === undefined) return undefined
483
- return evidence.append("provider-stdout", chunk).then(() => { evidencePayloadBytes += chunk.length })
548
+ return Promise.all([
549
+ evidence.append("provider-stdout", chunk),
550
+ recordEvidenceEvent({
551
+ kind: "provider_stdout",
552
+ artifactId: evidence.metadata.id,
553
+ provider: parsed.provider,
554
+ metadata: {length: chunk.length}
555
+ })
556
+ ]).then(() => { evidencePayloadBytes += chunk.length })
484
557
  },
485
558
  onStderrChunk: (chunk) => {
486
559
  metrics.recordRawChildChunk("provider_stderr", chunk.length)
487
560
  metrics.recordRejected("stderr", chunk.length)
488
561
  if (evidence === undefined) return undefined
489
- return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
562
+ return Promise.all([
563
+ evidence.append("provider-stderr", chunk),
564
+ recordEvidenceEvent({
565
+ kind: "provider_stderr",
566
+ artifactId: evidence.metadata.id,
567
+ provider: parsed.provider,
568
+ metadata: {length: chunk.length}
569
+ })
570
+ ]).then(() => { evidencePayloadBytes += chunk.length })
490
571
  }
491
572
  }
492
573
  let exitCode
@@ -520,6 +601,12 @@ export async function main(arguments_, dependencies = {}) {
520
601
  const providerResult = admission.createProviderResult(terminal)
521
602
  const serializedProviderResult = `${JSON.stringify(providerResult)}\n`
522
603
  await resultEvidence.append("provider-result", serializedProviderResult)
604
+ await recordEvidenceEvent({
605
+ kind: "provider_result",
606
+ artifactId: resultEvidence.metadata.id,
607
+ provider: parsed.provider,
608
+ metadata: {terminal, length: Buffer.byteLength(serializedProviderResult, "utf8")}
609
+ })
523
610
  const resultArtifact = await resultEvidence.finalize()
524
611
  resultEvidenceBytes = resultArtifact.bytes
525
612
  resultEvidenceFinalized = true
@@ -550,7 +637,14 @@ export async function main(arguments_, dependencies = {}) {
550
637
  evidencePayloadBytes + resultEvidenceBytes,
551
638
  2
552
639
  )
553
- await evidence.append("context-metrics", `${JSON.stringify(metrics.snapshot())}\n`)
640
+ const metricsSnapshot = `${JSON.stringify(metrics.snapshot())}\n`
641
+ await evidence.append("context-metrics", metricsSnapshot)
642
+ await recordEvidenceEvent({
643
+ kind: "context_metrics",
644
+ artifactId: evidence.metadata.id,
645
+ provider: parsed.provider,
646
+ metadata: {length: Buffer.byteLength(metricsSnapshot, "utf8")}
647
+ })
554
648
  const artifact = await evidence.finalize()
555
649
  assertArtifactProjection(artifact.bytes + resultEvidenceBytes, projectedArtifactBytes)
556
650
  } catch (error) {
@@ -563,6 +657,13 @@ export async function main(arguments_, dependencies = {}) {
563
657
  } else if (resultEvidenceFinalized) {
564
658
  metrics.recordArtifact(resultEvidenceBytes, 1)
565
659
  }
660
+ if (evidenceStore !== undefined && evidenceOwner !== undefined && evidenceError === undefined) {
661
+ try {
662
+ await evidenceStore.finalizeRunIndex(evidenceOwner, terminal)
663
+ } catch (error) {
664
+ evidenceError ??= error
665
+ }
666
+ }
566
667
  admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
567
668
  if (
568
669
  destination.type === "telegram" && resultEvidenceFinalized