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 +118 -0
- package/README.md +5 -3
- package/docs/container-runtime.md +2 -2
- package/docs/development-container.md +64 -20
- package/docs/evidence-artifacts.md +47 -0
- package/package.json +5 -3
- package/scripts/verify-package.js +2 -0
- package/src/cli.js +124 -23
- package/src/evidence-index.js +664 -0
- package/src/evidence-store.js +180 -44
- package/src/isolated-runtime.js +1 -1
- package/src/kimi-model-broker.js +18 -2
- package/src/redaction.js +50 -0
- package/src/run-worker.js +10 -3
- package/src/telegram-ingress/core.js +66 -6
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
|
|
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
|
|
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
|
|
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
|
+
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 `
|
|
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
|
|
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
|
-
- `
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
Hermes owns the canonical
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
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": ">=
|
|
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": "
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|