threadwire
v0.1.44
Published
Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination
Maintainers
Readme
Threadwire
Threadwire is a Node.js CLI that launches a Codex, Claude, Kimi, OpenCode, or Qwen Code worker and either presents its lifecycle and final result at one explicitly selected Telegram destination or writes a normalized result to an explicit local file target. The default Telegram presentation is one mutable status card plus at most one bounded trusted terminal result. Assistant narration, tool details, evidence handles, and activity pages remain local unless a detailed presentation is explicitly selected.
For the container-native webhook deployment, including its named-volume worktree,
non-root providers, Docker secrets, loopback TLS proxy, verification, and
rollback path, see Container runtime. Its read-only
service mounts independent writable state volumes for Codex and Qwen; Qwen uses
the fixed QWEN_HOME=/home/threadwire/.qwen state path. The legacy host launcher
below remains supported and is separate from that deployment.
Relay-originated Codex writes are explicit and default off. --relay-write
selects the optional root-owned isolated runtime and credential broker. The worker gets one writable task worktree, no upstream
or Git credential, and no route except its run-scoped broker. Missing
prerequisites fail before prompt or credential reads with no native fallback.
The isolated runtime imposes no default full-run deadline: a healthy running
worker is never stopped merely because time elapsed. An operator may set
THREADWIRE_WORKER_TIMEOUT_MS (supervisor) and
THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS (client) to a positive
millisecond value to enable one absolute preflight+run deadline; empty or
unset means unlimited. Broker grants use a bounded lease that the supervisor
renews while the run is active and that expires within one lease if the
supervisor dies; unrelated safety timeouts (preflight slice, per-operation
Docker/network calls, state collection, emergency cleanup) are unchanged.
See Isolated provider runtime.
Run with npx
Threadwire requires Node.js 24 or newer. Run the published CLI without a global installation:
npx --yes threadwire@latest --help
npx --yes threadwire@latest run \
--provider codex \
--target telegram:-1001234567890:42 \
--cwd /path/to/repository \
--prompt 'Inspect the project.'Provide THREADWIRE_TELEGRAM_BOT_TOKEN through a trusted environment or secret
manager; do not put the token in the command line. Use
--provider-bin /absolute/path/to/project-adapter when one run must select an
explicit provider executable or container adapter; the per-run option takes
precedence over ambient provider variables and prevents one project's adapter
from leaking into another project. Codex otherwise first uses a valid
THREADWIRE_CODEX_BIN, then the first executable codex found by left-to-right
Unix lookup in the supplied PATH; if no safe candidate is found, it runs the
normal codex command. Relative and empty PATH
entries resolve from the worker's --cwd. Candidates are canonically resolved
and inspected before use; known Threadwire front doors such as
/opt/data/bin/codex, including aliases to them, are rejected while lookup
continues, preventing recursive relay. A stale override warns with the actual
selected fallback. Use
THREADWIRE_CODEX_BIN only for an intentional custom/container adapter. For
Claude, OpenCode, or Qwen Code, use THREADWIRE_CLAUDE_BIN,
THREADWIRE_OPENCODE_BIN, or THREADWIRE_QWEN_BIN respectively; those
overrides must name direct provider executables, never Threadwire front-door
shims.
To install the same commands globally instead:
npm install --global threadwire
threadwire --helpUsage
Use the local launcher. Install or refresh it after updating Threadwire with npm run install:local-launcher. It first validates the Threadwire-owned arguments with a token-free check, so a missing or malformed provider or target fails with Threadwire's own safe error before the dotenv is opened. Only for a valid invocation does it then read only TELEGRAM_BOT_TOKEN from the trusted /opt/data/.env, map it to THREADWIRE_TELEGRAM_BOT_TOKEN for Threadwire, and never source the wider dotenv. Threadwire never supplies routing defaults and never reads routing from a prompt or repository.
/opt/data/bin/threadwire run \
--provider codex \
--target telegram:-1001234567890:42 \
--process-number 42 \
--cwd /path/to/repository \
--prompt 'Inspect the project.' \
-- --model example-modelThe target has the form telegram:<chat-id>:<thread-id> for topics, telegram:<chat-id> for direct messages (DMs) and ordinary chats, or file:<absolute-path> for local no-message execution. A file target requires a normalized absolute file path, needs no Telegram token, creates the file exclusively with mode 0600, fsyncs it and its directory, and writes the exact same single normalized delegated_result record as stdout. It cannot be combined with --transcript. Telegram chat IDs and optional thread IDs keep their existing validation. --provider must be exactly one of codex, claude, kimi, opencode, or qwen. Malformed or missing provider or target arguments are hard failures (exit code 2) reported through Threadwire's normal error path before any provider is launched. --process-number accepts an explicit positive safe integer for automation; when omitted, Threadwire uses its launcher PID. Every Telegram message is labeled consistently, such as [P42] …, including chunked continuations.
--cwd is the complete workspace contract for every provider. It may name any caller-accessible directory, including a non-Git directory. Threadwire passes it unchanged to the native provider and performs no profile, repository, source-identity, task-binding, or root-allowlist lookup. --workspace-profile has been removed and is rejected; migrate invocations by replacing it with --cwd <directory>.
--telegram-presentation accepts card, activity, quiet, or verbose and defaults to card. Card mode sends one mutable worker status card and, when the normalized provider result contains assistant content, exactly one plain-text terminal result message capped at Telegram's 4,096 post-entity-parsing character limit with an explicit truncation marker. It does not send assistant deltas, tool details/output, evidence handles, or expandable pages, so activity-page rollover cannot occur. Every card, quiet, and activity status render shows elapsed <duration> · time zone <canonical IANA zone>, last activity DD.MM.YYYY HH:mm:ss, tools <n> finished / <n> active, and either last tool finished none yet or the latest normalized tool-finish observation. Only normalized tool/finished events advance that projection-local finish instant; every normalized event advances the activity observation. A failed invocation already normalized as finished counts, while worker failure or cancellation does not synthesize a finish. These timestamps are evidence of observed progress, not proof that the worker process is currently live. Suppressed assistant deltas update local activity and quiet detection without dirtying the card; clock-only changes to elapsed never cause an edit. Delayed mutations refresh elapsed immediately before delivery while retaining the recorded observation times and adding no Telegram traffic. Routine tool totals become visible at deterministic 1/2/5 × 10ⁿ milestones, and a continuously active worker may request an activity-qualified heartbeat no more than once every two minutes. Silence produces one quiet transition, while terminal cards always show exact counters. quiet is request-minimal: it emits only initial, quiet/recovery, and terminal status, with no routine counter edits, heartbeat, activity pages, or successful terminal prose. Failed cards in all three card modes include a sanitized reason and the actual exit code; see Failure diagnostics. activity explicitly enables the former card behavior: bounded collapsed, expandable HTML activity pages whose active page is edited and rolled over at the safe boundary. verbose preserves the legacy per-notice renderer. Card and activity require Telegram message-edit support and fail clearly when it is unavailable; they never silently downgrade to verbose. file: targets remain local result sinks and require no message editing.
Telegram status timestamps default to UTC. For a direct Telegram run, --telegram-time-zone <IANA zone> overrides THREADWIRE_TELEGRAM_TIME_ZONE, which overrides UTC; embedded WorkerControl callers can set telegramTimeZone. The webhook service reads THREADWIRE_TELEGRAM_TIME_ZONE once at startup and copies that immutable service default into each new run. Each card prints the canonical zone once. UTC timestamps omit a redundant suffix; named zones retain the numeric offset effective at each observation instant, including UTC+00:00 for named zero-offset zones. Europe/Berlin, for example, can therefore show distinct offsets for activity and tool finish across a daylight-saving boundary. Only exact UTC or slash-qualified named IANA zones accepted by Node/ICU are valid; aliases are canonicalized, while empty, padded, fixed-offset, abbreviated, fixed Etc/GMT*, unknown, control-character, and excessive-length values fail before transport or provider startup. An explicit CLI timezone is invalid for file: targets, and ambient timezone configuration is ignored for them. Formatting remains inside Telegram's 4,096-character limit and creates no extra Telegram requests.
--tool-messages affects detailed tool content only in activity and verbose. Activity mode otherwise includes sanitized assistant and fixed lifecycle/diagnostic entries; with the flag it additionally admits safe tool start/completion descriptions and allowed normalized Codex command output. While running or successful, card and quiet show only aggregate tool counters and the local tool-finish timestamp, without a current command, tool name, path, arguments, detail, output, or provider metadata. Credential-bearing values are redacted, control characters stripped, and activity content HTML-escaped and Unicode-safe bounded. Prompts, reasoning, raw provider metadata/results, and non-Codex tool output are never admitted. Raw stderr remains suppressed; failed terminal cards alone may show a bounded, sanitized, explicitly labeled stderr excerpt when no stronger failure reason is available. --max-output-length bounds the safe tool-detail preview. For detailed troubleshooting, select --telegram-presentation activity --tool-messages. 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.
Threadwire directly spawns the structurally separate terminal adapters /opt/data/libexec/threadwire/codex and /opt/data/libexec/threadwire/claude, or the native kimi, opencode, and qwen executables resolved from PATH, with an argv array and shell: false. Qwen's prompt is never an argv argument: Threadwire writes it to the provider child's stdin and closes stdin after the write, treating an input pipe that the provider closes early as expected input lifecycle. The libexec adapters 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, THREADWIRE_OPENCODE_BIN, or THREADWIRE_QWEN_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.
For Codex in ordinary project images, the fixed structural path described above
is only the preferred fallback after a valid override. When that adapter is not
executable, Threadwire uses the first inspected safe codex candidate from
PATH instead.
Provider front-door shims
/opt/data/bin/codex and /opt/data/bin/claude are Threadwire front-door relay shims. Install or refresh both them and their paired libexec adapters with npm run install:provider-shims; the installer generates all four scripts from the repo templates and never modifies runtime artifacts on its own. OpenCode, Kimi, and Qwen remain native executables and are launched explicitly through threadwire run --provider .... A shimmed worker invocation is routed through the /opt/data/bin/threadwire launcher so its activity streams to the caller's explicit Telegram destination; every non-worker mode runs the real provider CLI unchanged through the paired libexec adapter.
A trusted host controller can generate its launcher with an absolute, controller-only Telegram bot-token file:
node scripts/install-local-launcher.js /opt/data/bin/threadwire \
--telegram-bot-token-file /run/secrets/threadwire_telegram_bot_tokenAfter token-free argument validation, the launcher reads at most 8 KiB from a
regular, non-symlink file that is not group/other-writable, exports
THREADWIRE_TELEGRAM_BOT_TOKEN only to the Threadwire controller, and clears
the file pointer. Threadwire's existing provider boundary removes the token and
pointer before spawning any provider. The token file and value are never passed
through Docker argv/environment, mounted or copied into the task container or
source, added to prompts, or written to logs. Omitting the option preserves the
existing TELEGRAM_BOT_TOKEN dotenv lookup.
The relay contract: the caller supplies THREADWIRE_TARGET (telegram:<chat-id> or telegram:<chat-id>:<thread-id>) and, optionally, THREADWIRE_PROCESS_NUMBER. The prompt arrives on a piped stdin; all native provider flags pass through after --. A positional prompt is never parsed. A missing THREADWIRE_TARGET fails closed (exit code 2) and a malformed one is rejected by the launcher's token-free preflight — the shim never invents a default destination and never silently downgrades a requested relay to a direct run. A shim relays only for a worker mode (codex exec … or any non-interactive claude …); it drops the worker subcommand before relaying and Threadwire re-adds it around the prompt. Version and help queries, non-worker subcommands, interactive TTY sessions, and any invocation already inside a relayed run (THREADWIRE_ACTIVE=1, so nested provider workers stay local/native) run the real CLI directly through the libexec adapter.
Pass --resume-session <exact-provider-session-id> to continue a stored Codex, Claude, Kimi, OpenCode, or Qwen session. Threadwire preserves the provider's native resume ordering and streaming options and never uses implicit latest-session behavior. For Qwen, Threadwire feeds the owned prompt through the provider child's stdin with --input-format text, --output-format stream-json, --include-partial-messages, and the exact --resume <session-id> continuation flag; it owns those protocol fields while the caller explicitly chooses any model, approval, sandbox, and budget options.
Pass --transcript <path> with a Telegram target 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. A file:<absolute-path> target provides this capture directly and therefore rejects a separate --transcript. Neither form contains provider JSONL, reasoning, tool payloads, prompts, stderr, or credentials. The result/transcript path and --activity-log path must not canonically alias; final-component symlinks and aliased parent directories are rejected during preflight before either output is opened.
--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.
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.
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.
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 for the complete schema, limits, and caller obligations.
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. Default card and quiet presentations keep evidence handles local; explicit activity and verbose presentations retain their detailed handle notice. Retrieve later with threadwire evidence read, inspect the per-run SQLite index
with threadwire evidence inspect, or use the authenticated /evidence Telegram
command with an explicit bounded selector. See Evidence artifacts for permissions, delivery states, quotas, retention, recovery, authorization, redaction, privacy, cleanup, and the inspect fixed-selector contract.
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 for the schema, tie-break, error taxonomy, and security boundary.
Telegram is the separate user-visible presentation plane. By default it receives only the mutable lifecycle card (including a sanitized reason and exit code on failure) and one bounded normalized terminal result; assistant streaming, routine diagnostics, tool details, evidence handles, and activity pages remain local. Explicit activity or verbose presentation enables detailed progress, and --tool-messages affects only those modes. 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.
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 controller-only THREADWIRE_TELEGRAM_TIME_ZONE setting is also stripped rather than used as an ambient provider or container timezone. The three libexec adapters repeat the credential 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.
A single trusted renderer owns Telegram projection. The default card consumes normalized events only to maintain fixed lifecycle state, activity age, aggregate tool counters, and the controller-observed time of the latest normalized tool finish; it discards assistant deltas and tool detail. Its successful terminal prose comes from the already normalized and sanitized provider result, never from rendered-delta reconstruction, and is sent once with explicit truncation rather than paging. Stream IDs, raw records, raw stderr, prompts, reasoning, and evidence handles are not rendered. Failed cards admit only sanitized allowlisted provider errors, primary execution messages, or a bounded labeled stderr excerpt; otherwise they explicitly report the actual exit code and missing details. This run-local collection does not require evidence storage and never changes worker lifecycle or parent-result/activity-log schemas. Explicit activity mode credential-redacts, control-strips, HTML-escapes, and bounds assistant text and opted-in safe tool activity inside one non-nested <blockquote expandable> per independently valid page. Status and activity sends/edits are silent. Permanent loss of the active card or page permits at most one replacement; transient failures retain bounded Retry-After/backoff retries. The verbose renderer retains the prior labeled sentence/code and editable-tool behavior.
In explicit activity and verbose modes, assistant deltas from all provider stream IDs share one ordered sentence-boundary buffer, preserving provider-event order. The buffer has a finite 16,384-character default: a continuously active punctuation-free stream emits and clears Unicode-safe fixed-size chunks instead of growing indefinitely. Structured events flush any earlier assistant tail before their own fixed notice. When Telegram briefly falls behind, detailed delivery remains bounded and coalesces only eligible assistant progress. Card mode does not enter this buffering or activity-page rollover. A provider result is sealed independently before final Telegram delivery: exhausted delivery retries return a delivery failure without rerunning the provider or changing its completed normalized result.
The delivery queue has finite defaults of 100 pending notices and 256 KiB of pending UTF-8 text. All Telegram sends and edits pass through a SQLite-backed per-bot/per-chat mutation gate with a configurable 5-second default absolute interval. Routine card mutations additionally share a fixed two-minute budget across cooperating workers; initial, quiet/recovery, and terminal mutations bypass that routine cadence but still obey the absolute floor and shared 429 cooldown. Forum topics in one chat share both pacing budgets; separate chats do not. Processes coordinate only when they share THREADWIRE_TELEGRAM_RATE_STATE_PATH (default $HOME/.local/state/threadwire/telegram-rate.sqlite); the database stores only one-way scope identities and timing/lease state, never payloads, message text, bot/chat material, or raw failure descriptions. Pending card coalescing is process-local and keeps at most the latest routine intent per worker. Message length and capacity settings require positive safe integers; pacing settings require nonnegative safe integers. A notice larger than the byte limit or a limit-plus-one enqueue latches a terminal capacity error, discards queued (not already in-flight) notices, and rejects every later enqueue. Unicode chunks never split a surrogate pair, and every labeled continuation retains its complete process label. If the label cannot fit with content under a configured message length, delivery latches a terminal chunking error before sending. These rules make delivery loss visible and prevent slow or rate-limited transports from consuming unbounded memory. A persisted delivery_failed state means Threadwire received no successful response after the bounded attempts. A timeout or dropped response can be ambiguous, so Threadwire guarantees provider non-rerun but does not claim exactly-once Telegram display.
Development
The checked-in development container is the app service in compose.dev.yaml
(Ubuntu 26.04 pinned by digest, non-root 1000:1000, Node 24 LTS, npm-latest
coding CLI baseline). Setup, the read-only GitHub CLI and agent-context mount
contracts, and container commands are documented in AGENTS.md.
The development and standard runtime images install Qwen Code from the bare
@qwen-code/qwen-code package, which selects the latest release available at
image-build time. Qwen authentication/configuration remains provider-owned;
Threadwire neither bakes credentials nor selects a model, approval mode,
sandbox, or budget for callers.
Tests use the public @velocious/testing DSL with synthetic provider records,
a local fixture child, and injected transports. They never invoke Codex,
Claude, or Telegram. Run one focused file with
npx velocious-test test/<file>.test.js; CI owns the aggregate suite.
npm test
npm run eslint
npm run typecheck
npm run fallow
npm run lint
npm run all-checksTensorBuzz CI runs these from the repo root; tensorbuzz.yml is the source of truth for the exact CI command sequence (node -v, npm ci, npm run all-checks).
npm run verify:package builds the publishable tarball in a temporary directory,
checks its allowlisted contents, and executes threadwire --help from that exact
tarball through npm's package runner without using the network.
License
Copyright © 2026 Kasper Stöckel.
Threadwire is licensed under the GNU Affero General Public License v3.0 or later. If you distribute a modified version, or let users interact with a modified version over a network, the AGPL requires you to offer those users the corresponding source code under the same license.
