threadwire 0.1.15 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.18 - 2026-08-06
6
+
7
+ - Autodiscover the direct Codex CLI for both worker runs and live capacity probes: after a valid `THREADWIRE_CODEX_BIN` and executable structural `/opt/data/libexec/threadwire/codex` adapter, resolve executable `codex` candidates in supplied `PATH` order while rejecting the `/opt/data/bin/codex` Threadwire front door and continuing lookup. Stale overrides name the selected fallback, and no safe candidate fails clearly without unchecked bare-command lookup.
8
+
9
+ ## 0.1.17 - 2026-08-06
10
+
11
+ - Persist a normalized `provider_result` evidence artifact before final notification drain, separating it from quota-limited raw prompt/provider-stream evidence and separating `provider_completed`/`provider_failed` from authenticated `delivery_pending`/`delivery_succeeded`/`delivery_failed` state. Only durably finalized raw handles enter normalized results. Exhausted Telegram Retry-After/backoff attempts no longer rewrite provider success or discard its continuation/evidence handles, and ingress evidence-handle notices now use the existing bounded notice queue instead of an unbounded direct send. Isolated completion rejection and deadline cancellation now durably publish `provider_failed` with already observed assistant, continuation, and raw evidence before propagating the execution failure. Delivery transitions recover the exact sealed artifact redaction policy across restart, preserve its durable reservation, and record setup failures as `delivery_failed`. Add the token-free `file:<absolute-path>` target for private, fsynced, byte-identical normalized stdout capture without message delivery; result/transcript and activity paths now reject final symlink and parent-directory aliases before opening either output. Delivery failure records lack of a confirmed response, not exactly-once Telegram semantics after an ambiguous network failure.
12
+
13
+ ## 0.1.16 - 2026-08-04
14
+
15
+ - Validate provider executable overrides at resolution time instead of deferring to spawn: `providerExecutable()` checks that a `THREADWIRE_*_BIN` override path exists, is executable (`X_OK`), and is a regular file (not a directory), falling back to the default with a diagnostic when the validation fails. This catches bogus overrides, stale temporary adapter paths, and directory targets before `runWorker` hits EACCES. The full test suite now uses real temporary executables instead of fake paths that relied on the old unchecked pass-through.
16
+
5
17
  ## 0.1.15 - 2026-08-03
6
18
 
7
19
  - Add machine-readable run health supervision: the existing append-only `--activity-log` now records safe timestamped controller/provider/session/activity/health/terminal facts with an explicit `controller-started` fact (the run controller's own PID, not an injected status-process PID). The `threadwire status --activity-log <absolute-path>` read-only command emits exactly one closed versioned JSON document (`version`, `state`, `provider`, `controllerPid`, `providerPid`, `continuationHandle`, `startedAt`, `lastActivityAt`, `quietForMs`, `health`, `terminal`). PIDs are recorded non-authoritative facts; status never inspects or signals processes. Health events carry a bounded disposition (`retrying`/`blocked`) plus safe category (`authentication`/`permission`/`rate-limit`/`quota`/`billing`/`model`/`network`/`protocol`/`unknown`) and optional `retryAfterMs` in milliseconds, extracted only from an explicit small list of safe provider record containers (top-level record and known nested `error`/`part`/`part.error`/`.data` shapes) — never recursive traversal and never message/text regex. Generic HTTP 429 → retrying/rate-limit; structured `exceeded_current_quota_error`/`insufficient_quota`/`insufficient_balance` → blocked quota/billing; ambiguous short fields such as `retry_after` (seconds) are never interpreted as milliseconds. If a provider protocol suppresses upstream error details (e.g., real Moonshot OpenCode sessions), no health fact is recorded and status stays running/unknown; Hermes must perform an independent bounded provider probe. Activity records are throttled (5 s) with guaranteed first-activity recording. Terminal facts always override earlier retrying/blocked health state and are written exactly once for local and isolated-runtime run paths; signal-derived exit codes 130/143 record as `cancelled`, all others as `completed` (0) or `failed` (nonzero). A later normal activity record clears a prior `retrying` health back to running; `blocked` stays sticky until terminal overrides it. The status command reads the log within a documented 1 MiB byte cap, never opens the file writable, skips at most one incomplete trailing JSONL record, rejects malformed/schema-unsafe records and overlong/malformed continuation handles, validates timestamps as safe non-negative Date-range integers and the injected clock similarly, and never claims stalled/dead from silence. Existing backward-compatible records without timestamps remain readable.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Threadwire
2
2
 
3
- Threadwire is a Node.js CLI that launches a Codex, Claude, Kimi, or OpenCode worker and streams a concise, human-readable activity feed to one explicitly selected Telegram destination: a direct message, normal chat, or forum topic. The default feed includes the assistant's response prose, lifecycle outcomes, and diagnostics. Provider tool-status messages are disabled by default.
3
+ Threadwire is a Node.js CLI that launches a Codex, Claude, Kimi, or OpenCode worker and either streams a concise, human-readable activity feed to one explicitly selected Telegram destination or writes a normalized result to an explicit local file target. The default Telegram feed includes the assistant's response prose, lifecycle outcomes, and diagnostics. Provider tool-status messages are disabled by default.
4
4
 
5
5
  For the container-native webhook deployment, including its named-volume worktree,
6
6
  non-root Codex provider, Docker secrets, loopback TLS proxy, verification, and
@@ -30,21 +30,29 @@ installation:
30
30
  ```sh
31
31
  npx --yes threadwire@latest --help
32
32
 
33
- THREADWIRE_CODEX_BIN=/path/to/direct/codex \
34
- npx --yes threadwire@latest run \
35
- --provider codex \
36
- --target telegram:-1001234567890:42 \
37
- --cwd /path/to/repository \
38
- --prompt 'Inspect the project.'
33
+ npx --yes threadwire@latest run \
34
+ --provider codex \
35
+ --target telegram:-1001234567890:42 \
36
+ --cwd /path/to/repository \
37
+ --prompt 'Inspect the project.'
39
38
  ```
40
39
 
41
40
  Provide `THREADWIRE_TELEGRAM_BOT_TOKEN` through a trusted environment or secret
42
- manager; do not put the token in the command line. For Claude or OpenCode, use
43
- `THREADWIRE_CLAUDE_BIN` or `THREADWIRE_OPENCODE_BIN` respectively. The override
44
- must name the direct provider executable, never a Threadwire front-door shim,
45
- so the relay cannot recursively launch itself. Existing Threadwire hosts may
46
- omit the override when their reviewed `/opt/data/libexec/threadwire/*` adapters
47
- are installed.
41
+ manager; do not put the token in the command line. Codex first uses a valid
42
+ `THREADWIRE_CODEX_BIN`, then the executable structural adapter at
43
+ `/opt/data/libexec/threadwire/codex`, then the first executable `codex` found by
44
+ left-to-right Unix lookup in the supplied `PATH`. Relative and empty PATH
45
+ entries resolve from the worker's `--cwd`. Candidates are canonically resolved
46
+ and inspected before use; known Threadwire front doors such as
47
+ `/opt/data/bin/codex`, including aliases to them, are rejected while lookup
48
+ continues, preventing recursive relay. A stale override warns with the actual
49
+ selected fallback, and no safe candidate retains the explicit structural path
50
+ for the normal unavailable failure rather than spawning an unchecked bare
51
+ command. Use
52
+ `THREADWIRE_CODEX_BIN` only for an intentional custom/container adapter. For
53
+ Claude or OpenCode, use `THREADWIRE_CLAUDE_BIN` or `THREADWIRE_OPENCODE_BIN`
54
+ respectively; those overrides must name direct provider executables, never
55
+ Threadwire front-door shims.
48
56
 
49
57
  To install the same commands globally instead:
50
58
 
@@ -68,7 +76,7 @@ Use the local launcher. Install or refresh it after updating Threadwire with `np
68
76
  -- --model example-model
69
77
  ```
70
78
 
71
- The target always has the form `telegram:<chat-id>:<thread-id>` for topics, or `telegram:<chat-id>` for direct messages (DMs) and ordinary chats. The chat ID is always required and must be a nonzero signed integer. The thread ID is optional and valid only as the explicit topic suffix: when present it must be a positive safe integer, and a DM target simply omits it a missing thread ID is never an error. `--provider` must be exactly one of `codex`, `claude`, `kimi`, or `opencode`. Malformed or missing provider, chat ID, or thread ID arguments are hard failures (exit code 2) reported through Threadwire's normal error path with fixed messages; they fail before the Telegram token is read, before any transport is created, and before any provider is launched, and the messages never echo the bot token or the untrusted argument value. `--process-number` accepts an explicit positive safe integer for automation; when omitted, Threadwire uses its launcher PID. Every Telegram message is labeled consistently, such as `[P42] …`, including chunked continuations.
79
+ The 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`, or `opencode`. 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.
72
80
 
73
81
  `--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>`.
74
82
 
@@ -76,6 +84,11 @@ Provider tool-start and tool-finish events do not produce Telegram messages by d
76
84
 
77
85
  Threadwire directly spawns the structurally separate terminal adapters `/opt/data/libexec/threadwire/codex`, `/opt/data/libexec/threadwire/claude`, `/usr/local/bin/kimi`, or `/opt/data/libexec/threadwire/opencode-local-fleet` with an argv array and `shell: false`. These libexec adapters hold the real provider-CLI logic and are never on a worker's `PATH`, so relaying can never recurse back into the front-door shims. Each spawn also sets `THREADWIRE_ACTIVE=1` in the provider child environment. The executable can be overridden per provider with `THREADWIRE_CODEX_BIN`, `THREADWIRE_CLAUDE_BIN`, `THREADWIRE_KIMI_BIN`, or `THREADWIRE_OPENCODE_BIN` for staged cutover and rollback. It does not impose a worker timeout. `SIGINT` and `SIGTERM` are forwarded to the worker as explicit cancellation. Terminal completion is explicit and provider-owned rather than derived only from process exit: native Kimi confirms a completed run with a final assistant response (assistant content without further tool calls) plus its session handoff record in the stream. Once both records are confirmed, Threadwire preserves the session continuation handle, terminates the attempt-owned provider process group (`SIGTERM`), and emits exactly one `delegated_result`. Success settles only once the entire owned group is gone: members that survive `SIGTERM` — including attempt-owned grandchildren — keep the grace-period escalation running and receive `SIGKILL` against the exact group before settlement, so no descendant is leaked even when the direct provider exits first. A provider that exits nonzero before its protocol confirms completion keeps its failure status, and long-running tool activity remains unbounded: there is no generic idle timeout, only caller cancellation or an explicit deadline. Parent `SIGINT`/`SIGTERM` is owned cancellation, not just forwarding: the signal reaches the exact attempt-owned group, surviving members are escalated to `SIGKILL` after the grace period, and settlement keeps the forwarded signal's exit status (143/130 on Linux) only after the owned tree is gone. Relay/consumer failure cleans up the same owned tree — even when the failure lands after the direct child closed and only descendants survive — and the worker runner also accepts an explicit `AbortSignal`: an abort terminates the exact attempt-owned group with the same grace/escalation and settles with the abort failure, never success. After any `SIGKILL`, settlement waits for bounded cleanup confirmation — the exact group is re-probed, or on platforms without process groups the direct child's close is required — and cleanup that cannot be confirmed within the bounded window settles as failure, never success; this confirmation bound is not an idle or tool-activity timeout.
78
86
 
87
+ For Codex in ordinary project images, the fixed structural path described above
88
+ is only the preferred fallback after a valid override. When that adapter is not
89
+ executable, Threadwire uses the first inspected safe `codex` candidate from
90
+ `PATH` instead.
91
+
79
92
  ## Provider front-door shims
80
93
 
81
94
  `/opt/data/bin/codex`, `/opt/data/bin/claude`, and `/opt/data/bin/opencode-local-fleet` 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 six scripts from the repo templates and never modifies runtime artifacts on its own. A 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.
@@ -84,7 +97,7 @@ The relay contract: the caller supplies `THREADWIRE_TARGET` (`telegram:<chat-id>
84
97
 
85
98
  Pass `--resume-session <exact-provider-session-id>` to continue a stored Codex, Claude, Kimi, or OpenCode session. Threadwire preserves the provider's native resume ordering and streaming options and never uses implicit latest-session behavior.
86
99
 
87
- Pass `--transcript <path>` to capture the exact normalized, caller-facing JSONL protocol written to run stdout. The file is created privately (mode `0600`), must not already exist, and contains the same records in the same byte order as stdout. It never contains provider JSONL, reasoning, tool payloads, prompts, stderr, or credentials.
100
+ 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.
88
101
 
89
102
  `--activity-log <path>` is different: it retains its append contract and writes safe timestamped lifecycle facts—provider start (`provider`, child `pid`), available native session ID, throttled activity markers, structured health events (bounded `retrying`/`blocked` disposition with safe category), and exactly one terminal fact. It is local-only and never enters Telegram or normalized stdout.
90
103
 
@@ -94,7 +107,7 @@ Provider stdout is decoded as JSONL with a finite 1 MiB UTF-8 byte limit for eac
94
107
 
95
108
  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.
96
109
 
97
- Scoped raw evidence artifacts are separate from both files above. Set the trusted operator setting `THREADWIRE_EVIDENCE_ROOT` for standalone runs to persist the delegated prompt and raw provider stdout/stderr as a scoped file-backed artifact; Telegram ingress uses its dedicated evidence volume automatically. The parent or owning Telegram topic receives only an opaque bearer handle. Retrieve later with `threadwire evidence read` or the authenticated `/evidence` Telegram command and an explicit bounded selector. See [Evidence artifacts](docs/evidence-artifacts.md) for permissions, quotas, retention, recovery, authorization, redaction, privacy, and cleanup behavior.
110
+ 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.
98
111
 
99
112
  `threadwire capacity` is a read-only reporting command that probes live account capacity for authenticated Codex (`codex app-server` JSON-RPC `account/rateLimits/read`) and Kimi (`GET https://api.kimi.com/coding/v1/usages` with the installed OAuth credential), normalizes both into one closed short/long-window schema, and prints one deterministic reserve-gated admission selection as a single JSON document. It persists nothing, never prints tokens or account data, takes no session arguments, and does not change `run` provider selection; an external worker ledger consumes the report and owns pinning provider/model/session. See [Capacity admission](docs/capacity-admission.md) for the schema, tie-break, error taxonomy, and security boundary.
100
113
 
@@ -104,9 +117,9 @@ The Telegram bot token is used only by Threadwire's notifier and both `TELEGRAM_
104
117
 
105
118
  A single trusted renderer is the only code allowed to enqueue Telegram notices. By default, parsed assistant text is forwarded in order with every nonblank rendered line labeled `[P<n>] Assistant: `; no CLI flag is required. This preserves Markdown, code-block indentation, blank lines, and whitespace attached to nonblank content while preventing assistant text from imitating trusted activity on a later line. Streamed fenced JavaScript blocks (` ```js ` / ` ```javascript `) are instead emitted as independently valid, escaped Telegram HTML code messages: the `[P<n>] Assistant:` label stays above the code block, and long blocks are Unicode-safe chunked without exposing raw fences. A short blank-only stream is suppressed at normal close. Because retention is bounded, a blank-only prefix longer than the buffer limit is emitted in oldest-first chunks while at most one limit's worth remains deferred; a later visible assistant character therefore still reconstructs the input exactly, but an indefinitely blank stream cannot be suppressed indefinitely. With `--tool-messages`, tool activity uses a single edit-in-place status line (silent `🛠 …` on start, edited to begin `✅` on finish); without it, both the send and completion edit are suppressed. Worker start/completion/failure and warning/error diagnostics continue to use fixed labeled templates in either mode. Non-assistant provider fields—including summaries, tool names, metadata IDs, inputs, commands, stderr content, raw JSON, reasoning/thinking, tokens, and retry internals—are not forwarded. When tool messages are enabled, the sole provider-data exception is normalized Codex command-completion output: it is appended only to its original tool status edit as an escaped, bounded, expandable Telegram HTML blockquote after shared credential redaction.
106
119
 
107
- 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, including retained leading whitespace, before their own fixed notice. Completed boundaries enter delivery without a deliberate batching delay; messages combine only when already naturally queued behind delivery. When Telegram briefly falls behind, Threadwire emits a fixed safe notice that the notifier saturated and then coalesces only eligible assistant progress until headroom returns; lifecycle, diagnostics, and tool activity remain ordered and non-droppable. Notices remain ordered, size-limited, paced, and retried conservatively when Telegram reports throttling or a transient failure.
120
+ 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, including retained leading whitespace, before their own fixed notice. Completed boundaries enter delivery without a deliberate batching delay; messages combine only when already naturally queued behind delivery. When Telegram briefly falls behind, Threadwire emits a fixed safe notice that the notifier saturated and then coalesces only eligible assistant progress until headroom returns; lifecycle, diagnostics, and tool activity remain ordered and non-droppable. Notices remain ordered, size-limited, paced, and retried conservatively when Telegram reports throttling or a transient failure. A provider result is admitted independently: exhausted delivery retries return a delivery failure without rerunning the provider or changing its completed normalized result.
108
121
 
109
- The delivery queue has finite defaults of 100 pending notices and 256 KiB of pending UTF-8 text. 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.
122
+ The delivery queue has finite defaults of 100 pending notices and 256 KiB of pending UTF-8 text. 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.
110
123
 
111
124
  ## Development
112
125
 
@@ -51,10 +51,16 @@ pin before relying on it.
51
51
 
52
52
  - **Codex** spawns `codex app-server` and runs a bounded two-request JSON-RPC
53
53
  exchange over stdio: `initialize`, then `account/rateLimits/read`. The
54
- executable resolves via `THREADWIRE_CODEX_BIN` or the default libexec
55
- adapter, exactly like the Codex worker provider; the anti-recursion default
56
- is unchanged, so native container callers point the existing safe override
57
- at the real CLI (for example `THREADWIRE_CODEX_BIN=/usr/local/bin/codex`).
54
+ executable resolves exactly like the Codex worker provider: a valid
55
+ `THREADWIRE_CODEX_BIN`, the executable structural
56
+ `/opt/data/libexec/threadwire/codex` adapter, then the first executable
57
+ `codex` found by left-to-right Unix lookup in the supplied `PATH`. Each PATH
58
+ candidate is canonically resolved and inspected before use; relative and
59
+ empty entries resolve from the probe's working directory. Known Threadwire
60
+ front doors, notably `/opt/data/bin/codex` and aliases to it, are skipped
61
+ while lookup continues; if no safe candidate exists, the probe reports
62
+ unavailable without spawning an unchecked bare command. A stale explicit
63
+ override warns with the selected fallback.
58
64
  Only the quota projection of the result is parsed: `rateLimits.primary` maps
59
65
  to `short` and `rateLimits.secondary` to `long`; a `null` secondary yields a
60
66
  short-only snapshot, and a secondary shorter than the primary fails closed.
@@ -7,6 +7,10 @@ Threadwire has two deliberately separate output planes:
7
7
  - Standard output for a normal `threadwire run` is the parent-model admission
8
8
  plane. It contains exactly one newline-terminated terminal JSON envelope.
9
9
 
10
+ `--target file:<absolute-path>` disables message delivery and durably writes the
11
+ same admission record to that private, exclusive file before stdout. It requires
12
+ no Telegram token and cannot be combined with `--transcript`.
13
+
10
14
  Standard error remains a caller diagnostic channel and is never admission
11
15
  content. Provider stdout is private parser input.
12
16
 
@@ -108,6 +112,22 @@ references exactly. Streamed conclusion collection is credential-redacted,
108
112
  control-normalized, and deterministically truncated with a final ellipsis when
109
113
  necessary. Code-point accounting never splits a UTF-16 surrogate pair.
110
114
 
115
+ Provider and delivery outcomes are independent. When evidence is enabled, the
116
+ first artifact handle identifies a normalized `provider_result` containing the
117
+ terminal envelope and final assistant content. That artifact is atomically
118
+ published before final notification drain and records a stable delivery
119
+ identity/state. Any separate raw-evidence handle is admitted only after that
120
+ artifact is durable, so a late raw quota or finalization failure cannot leave a
121
+ stale recovery capability in either result. A provider exit zero remains
122
+ `completed` even when the process returns exit 2 because notification delivery
123
+ exhausted its bounded retries.
124
+ An isolated completion rejection or deadline still publishes `provider_failed`
125
+ with assistant content, continuation, and durable raw evidence observed before
126
+ the failure, then propagates the execution error after bounded cancellation and
127
+ cleanup settlement. Any cleanup or delivery failure remains attached without
128
+ masking that primary execution failure.
129
+ The continuation and evidence handles remain admitted for recovery.
130
+
111
131
  A continuation handle consists of one or more colon-separated opaque segments.
112
132
  Each segment must start with an ASCII letter or digit and may then contain only
113
133
  ASCII letters, digits, `_`, `-`, and `.`. This covers provider UUIDs, token IDs
@@ -6,11 +6,24 @@ parent admission stream. Telegram uses the dedicated
6
6
  with the trusted operator setting `THREADWIRE_EVIDENCE_ROOT`; callers cannot
7
7
  select a root through CLI arguments.
8
8
 
9
- The store records the prompt and framed provider stdout/stderr events, including
10
- tool calls, commands, raw results, and intermediate narration the provider
11
- emits. Threadwire stdout still contains one bounded `delegated_result`; evidence
12
- adds only an unguessable bearer handle. Storage failure fails the run explicitly
13
- and never falls back to inlining evidence.
9
+ For CLI and Telegram ingress runs, the store publishes a small normalized
10
+ result artifact separately from the prompt and framed provider stdout/stderr
11
+ artifact. The result contains one closed `provider_result` record with `provider_completed` or
12
+ `provider_failed`, the bounded final assistant content, the exact continuation,
13
+ and the terminal `delegated_result`. It is fsynced and atomically published
14
+ before final notification drain. A raw handle is admitted only after its
15
+ artifact is also durable; raw quota or finalization failure is surfaced without
16
+ invalidating the normalized result handle. The raw artifact still contains tool
17
+ calls, commands, raw results, and intermediate narration. CLI raw evidence also
18
+ ends with the final context metrics.
19
+ An isolated completion rejection or deadline first awaits bounded runtime
20
+ cancellation and cleanup settlement, then publishes `provider_failed` with
21
+ assistant content, continuation, and raw evidence observed before the failure
22
+ before propagating the execution error. Cleanup or delivery failures remain
23
+ attached to that primary execution failure.
24
+ Threadwire stdout remains one bounded `delegated_result`; evidence adds only
25
+ unguessable bearer handles. Storage failure fails explicitly and never falls
26
+ back to inlining evidence.
14
27
 
15
28
  ## Security and lifecycle
16
29
 
@@ -39,6 +52,25 @@ while active. Fsync and atomic rename publish payload before ready metadata.
39
52
  Startup removes partial/corrupt states and rehydrates only exact-schema,
40
53
  unexpired ready artifacts.
41
54
 
55
+ Result metadata additionally authenticates one stable delivery identity and a
56
+ closed state: `delivery_pending`, `delivery_succeeded`, `delivery_failed`, or
57
+ `delivery_not_requested`. The pending record is part of the initial atomic
58
+ publication. After the existing bounded notice queue settles, Threadwire
59
+ atomically replaces ready metadata and re-seals its redaction-policy AAD with
60
+ the terminal delivery state. The transition decrypts the exact artifact policy
61
+ from ready metadata rather than reconstructing it from runtime configuration or
62
+ an in-memory cache, preserving duplicate and artifact-specific redactions and
63
+ its durable reservation across restart. Retrieval returns this state with
64
+ result-artifact content; legacy artifacts without delivery metadata remain
65
+ readable.
66
+
67
+ `delivery_failed` means no successful transport response was confirmed,
68
+ including when notifier setup fails or the queue exhausts its bounded
69
+ Retry-After/backoff attempts. It does not prove that a
70
+ timed-out or response-lost Telegram request was not displayed. Recovery retries
71
+ must use the persisted provider result and must never rerun the provider;
72
+ Threadwire does not claim exactly-once Telegram send semantics.
73
+
42
74
  Defaults are 1 MiB/event; 64 MiB and 100,000 events/artifact; 128 MiB and
43
75
  200,000 events/run; 1 GiB, 1,000,000 events, and 10,000 artifacts/store;
44
76
  seven-day retention; and a 64 KiB/1,000-line serialized-response ceiling.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.15",
3
+ "version": "0.1.18",
4
4
  "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -52,6 +52,28 @@ if [[ "$threadwire_validation_status" -ne 0 ]]; then
52
52
  exit "$threadwire_validation_status"
53
53
  fi
54
54
 
55
+ threadwire_arguments=("$@")
56
+ threadwire_target=""
57
+ shift
58
+ while [[ "$#" -gt 0 ]]; do
59
+ case "$1" in
60
+ --) break ;;
61
+ --tool-messages|--relay-write) shift ;;
62
+ --target)
63
+ threadwire_target="$2"
64
+ shift 2
65
+ ;;
66
+ *)
67
+ if [[ "$#" -lt 2 ]]; then break; fi
68
+ shift 2
69
+ ;;
70
+ esac
71
+ done
72
+
73
+ if [[ "$threadwire_target" == file:* ]]; then
74
+ exec node "$THREADWIRE_ENTRYPOINT" "\${threadwire_arguments[@]}"
75
+ fi
76
+
55
77
  if [[ ! -r "$THREADWIRE_DOTENV_PATH" ]]; then
56
78
  printf 'threadwire: required dotenv is not readable: %s\\n' "$THREADWIRE_DOTENV_PATH" >&2
57
79
  exit 2
@@ -77,7 +99,7 @@ if [[ -z "$telegram_bot_token" ]]; then
77
99
  fi
78
100
 
79
101
  export THREADWIRE_TELEGRAM_BOT_TOKEN="$telegram_bot_token"
80
- exec node "$THREADWIRE_ENTRYPOINT" "$@"
102
+ exec node "$THREADWIRE_ENTRYPOINT" "\${threadwire_arguments[@]}"
81
103
  `
82
104
  }
83
105
 
package/src/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // @ts-check
2
2
 
3
- import {readFile} from "node:fs/promises"
3
+ import {lstat, readFile, readlink, realpath} from "node:fs/promises"
4
4
  import {randomUUID} from "node:crypto"
5
- import {isAbsolute, normalize, resolve} from "node:path"
5
+ import {basename, dirname, isAbsolute, join, normalize, resolve} from "node:path"
6
6
  import {stdin, stderr, stdout} from "node:process"
7
7
  import {createFetchTransport} from "./notifiers/fetch-transport.js"
8
8
  import {createTelegramSender, parseTelegramTarget} from "./notifiers/telegram.js"
@@ -30,7 +30,7 @@ import {
30
30
  import {probeCodexCapacity} from "./provider-capacity-codex.js"
31
31
  import {probeKimiCapacity} from "./provider-capacity-kimi.js"
32
32
 
33
- const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
33
+ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id> | file:<absolute-path>
34
34
  [--process-number <positive-integer>] [--cwd <directory>]
35
35
  [--relay-write]
36
36
  [--tool-messages] [--max-output-length <positive-integer>]
@@ -305,12 +305,20 @@ export async function main(arguments_, dependencies = {}) {
305
305
  await store.close()
306
306
  }
307
307
  }
308
- if (!validateOnly) {
309
- normalizedOutput = new NormalizedOutput(output, parsed.transcript)
308
+ const destination = parseDeliveryTarget(parsed.target)
309
+ if (destination.type === "file" && parsed.transcript !== undefined) {
310
+ throw new Error("--transcript cannot be combined with a file target")
310
311
  }
312
+ const transcriptPath = destination.type === "file" ? destination.path : parsed.transcript
313
+ if (
314
+ transcriptPath !== undefined && parsed.activityLog !== undefined
315
+ && await canonicalFilesystemPath(transcriptPath) === await canonicalFilesystemPath(parsed.activityLog)
316
+ ) {
317
+ throw new Error("Result and activity log paths must resolve to different filesystem paths")
318
+ }
319
+ if (!validateOnly) normalizedOutput = new NormalizedOutput(output, transcriptPath)
311
320
  const metrics = new ContextBudgetMetrics()
312
321
  runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
313
- const target = parseTelegramTarget(parsed.target)
314
322
  if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
315
323
  if (validateOnly) return 0
316
324
  const usesIsolatedRuntime = parsed.relayWrite
@@ -335,27 +343,63 @@ export async function main(arguments_, dependencies = {}) {
335
343
  let activity
336
344
  /** @type {Awaited<ReturnType<EvidenceStore["createArtifact"]>> | undefined} */
337
345
  let evidence
346
+ /** @type {Awaited<ReturnType<EvidenceStore["createArtifact"]>> | undefined} */
347
+ let resultEvidence
338
348
  /** @type {EvidenceStore | undefined} */
339
349
  let ownedEvidenceStore
350
+ /** @type {EvidenceStore | undefined} */
351
+ let evidenceStore
352
+ /** @type {{destinationId: string, runId: string} | undefined} */
353
+ let evidenceOwner
354
+ /** @type {WorkerControl | undefined} */
355
+ let control
356
+ /** @type {unknown} */
357
+ let deliveryError
358
+ let resultEvidenceFinalized = false
340
359
  let evidencePayloadBytes = 0
360
+ let resultEvidenceBytes = 0
361
+ const deliveryIdentity = randomUUID()
341
362
  const launchDeadline = isolatedPreflight?.deadline
342
363
  try {
343
- environment = await boundedLaunch(resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"]), launchDeadline)
364
+ environment = await boundedLaunch(resolveFileBackedSettings(
365
+ sourceEnvironment,
366
+ destination.type === "telegram" ? ["THREADWIRE_TELEGRAM_BOT_TOKEN"] : []
367
+ ), launchDeadline)
368
+ if (destination.type === "telegram" && !environment.THREADWIRE_TELEGRAM_BOT_TOKEN) {
369
+ throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
370
+ }
344
371
  const prompt = await readPrompt(parsed, dependencies.input ?? stdin, launchDeadline?.signal)
345
372
  const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
346
373
  if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
347
374
  ownedEvidenceStore = await boundedLaunch(EvidenceStore.open({root: configuredEvidenceRoot}), launchDeadline)
348
375
  }
349
- const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
376
+ evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
350
377
  if (evidenceStore !== undefined) {
351
- const evidenceOwner = dependencies.evidenceOwnerScope ?? evidenceStore.createOwnerScope({
352
- destinationId: `${target.chatId}:${target.threadId ?? "dm"}`,
353
- runId: randomUUID()
378
+ evidenceOwner = dependencies.evidenceOwnerScope ?? evidenceStore.createOwnerScope({
379
+ destinationId: destination.type === "telegram"
380
+ ? `${destination.target.chatId}:${destination.target.threadId ?? "dm"}`
381
+ : "file",
382
+ runId: deliveryIdentity
354
383
  })
384
+ const redactions = await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
355
385
  evidence = await evidenceStore.createArtifact(evidenceOwner, {
356
386
  contentType: "text/plain; charset=utf-8",
357
- redactions: await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
387
+ redactions
358
388
  })
389
+ try {
390
+ resultEvidence = await evidenceStore.createArtifact(evidenceOwner, {
391
+ contentType: "application/json",
392
+ redactions,
393
+ delivery: {
394
+ identity: deliveryIdentity,
395
+ state: destination.type === "telegram" ? "delivery_pending" : "delivery_not_requested"
396
+ }
397
+ })
398
+ } catch (error) {
399
+ await evidence.abort()
400
+ evidence = undefined
401
+ throw error
402
+ }
359
403
  }
360
404
  if (evidence !== undefined) {
361
405
  const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
@@ -371,20 +415,25 @@ export async function main(arguments_, dependencies = {}) {
371
415
  parsed.providerArguments,
372
416
  prompt,
373
417
  parsed.resumeSession,
374
- providerEnvironment
418
+ providerEnvironment,
419
+ {cwd: parsed.cwd}
375
420
  )
376
421
  if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
377
- const token = environment.THREADWIRE_TELEGRAM_BOT_TOKEN
378
- if (!token) throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
379
- const transport = (dependencies.transportFactory ?? createFetchTransport)(token, undefined, parseTelegramRequestTimeoutMs(environment))
380
- const control = new WorkerControl({
381
- sender: createTelegramSender(target, transport),
382
- processNumber: parsed.processNumber ?? process.pid,
383
- toolMessages: parsed.toolMessages,
384
- ...(dependencies.workerControlOptions ?? {}),
385
- ...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength}),
386
- metrics
387
- })
422
+ if (destination.type === "telegram") {
423
+ const transport = (dependencies.transportFactory ?? createFetchTransport)(
424
+ /** @type {string} */ (environment.THREADWIRE_TELEGRAM_BOT_TOKEN),
425
+ undefined,
426
+ parseTelegramRequestTimeoutMs(environment)
427
+ )
428
+ control = new WorkerControl({
429
+ sender: createTelegramSender(destination.target, transport),
430
+ processNumber: parsed.processNumber ?? process.pid,
431
+ toolMessages: parsed.toolMessages,
432
+ ...(dependencies.workerControlOptions ?? {}),
433
+ ...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength}),
434
+ metrics
435
+ })
436
+ }
388
437
  activity = parsed.activityLog === undefined ? undefined : new ActivityLog(parsed.activityLog)
389
438
  activity?.recordController(process.pid)
390
439
  /** @type {import("./run-worker.js").RunWorkerOptions} */
@@ -397,7 +446,7 @@ export async function main(arguments_, dependencies = {}) {
397
446
  parse: provider.parse,
398
447
  ...(provider.completion === undefined ? {} : {completion: provider.completion}),
399
448
  ...(launchDeadline === undefined ? {} : {signal: launchDeadline.signal}),
400
- onEvent: (event) => {
449
+ onEvent: async (event) => {
401
450
  validateNormalizedWorkerEvent(event)
402
451
  if (event.type !== "text-delta") {
403
452
  metrics.recordRejected(`${event.type}_progress`, Buffer.byteLength(JSON.stringify(event), "utf8"))
@@ -406,7 +455,13 @@ export async function main(arguments_, dependencies = {}) {
406
455
  else if (event.type === "tool") activity?.recordActivity(provider.name, "tool")
407
456
  else if (event.type === "lifecycle") activity?.recordActivity(provider.name, "lifecycle")
408
457
  acceptAdmissionEvent(admission, event)
409
- return control.accept(event)
458
+ if (control !== undefined) {
459
+ try {
460
+ await control.accept(event)
461
+ } catch (error) {
462
+ deliveryError ??= error
463
+ }
464
+ }
410
465
  },
411
466
  onSpawn: (pid) => {
412
467
  if (activity && pid !== undefined) activity.recordStarted(provider.name, pid)
@@ -456,27 +511,73 @@ export async function main(arguments_, dependencies = {}) {
456
511
  } else {
457
512
  exitCode = await (dependencies.workerRunner ?? runWorker)(workerOptions)
458
513
  }
459
- await boundedLaunch(control.close(), launchDeadline)
460
514
  terminalExitCode = exitCode
461
515
  } finally {
462
- if (evidence !== undefined) {
516
+ const terminal = {state: /** @type {"completed" | "failed"} */ (terminalExitCode === 0 ? "completed" : "failed"), exitCode: terminalExitCode}
517
+ if (evidence !== undefined && resultEvidence !== undefined) {
518
+ try {
519
+ admission.addArtifactHandle(resultEvidence.handle)
520
+ const providerResult = admission.createProviderResult(terminal)
521
+ const serializedProviderResult = `${JSON.stringify(providerResult)}\n`
522
+ await resultEvidence.append("provider-result", serializedProviderResult)
523
+ const resultArtifact = await resultEvidence.finalize()
524
+ resultEvidenceBytes = resultArtifact.bytes
525
+ resultEvidenceFinalized = true
526
+ } catch (error) {
527
+ await evidence.abort()
528
+ await resultEvidence.abort()
529
+ admission.removeArtifactHandle(resultEvidence.handle)
530
+ metrics.clearArtifact()
531
+ terminalExitCode = 2
532
+ evidenceError = error
533
+ }
534
+ } else {
535
+ await evidence?.abort()
536
+ await resultEvidence?.abort()
537
+ }
538
+ if (control !== undefined) {
539
+ try {
540
+ await boundedLaunch(control.close(), launchDeadline)
541
+ } catch (error) {
542
+ deliveryError ??= error
543
+ }
544
+ }
545
+ if (evidence !== undefined && resultEvidenceFinalized) {
546
+ admission.addArtifactHandle(evidence.handle)
463
547
  try {
464
- admission.addArtifactHandle(evidence.handle)
465
- const terminal = {state: /** @type {"completed" | "failed"} */ (terminalExitCode === 0 ? "completed" : "failed"), exitCode: terminalExitCode}
466
548
  metrics.recordAdmission(admission.preview(terminal))
467
- const projectedArtifactBytes = metrics.projectSelfInclusiveArtifact(evidencePayloadBytes, 1)
549
+ const projectedArtifactBytes = metrics.projectSelfInclusiveArtifact(
550
+ evidencePayloadBytes + resultEvidenceBytes,
551
+ 2
552
+ )
468
553
  await evidence.append("context-metrics", `${JSON.stringify(metrics.snapshot())}\n`)
469
554
  const artifact = await evidence.finalize()
470
- assertArtifactProjection(artifact.bytes, projectedArtifactBytes)
555
+ assertArtifactProjection(artifact.bytes + resultEvidenceBytes, projectedArtifactBytes)
471
556
  } catch (error) {
472
- await evidence.abort()
473
557
  admission.removeArtifactHandle(evidence.handle)
558
+ await evidence.abort()
474
559
  metrics.clearArtifact()
475
- terminalExitCode = 2
476
- evidenceError = error
560
+ metrics.recordArtifact(resultEvidenceBytes, 1)
561
+ evidenceError ??= error
477
562
  }
563
+ } else if (resultEvidenceFinalized) {
564
+ metrics.recordArtifact(resultEvidenceBytes, 1)
478
565
  }
479
566
  admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
567
+ if (
568
+ destination.type === "telegram" && resultEvidenceFinalized
569
+ && evidenceStore !== undefined && evidenceOwner !== undefined && resultEvidence !== undefined
570
+ ) {
571
+ try {
572
+ await evidenceStore.recordDelivery(
573
+ evidenceOwner,
574
+ resultEvidence.handle,
575
+ control !== undefined && deliveryError === undefined ? "delivery_succeeded" : "delivery_failed"
576
+ )
577
+ } catch (error) {
578
+ evidenceError ??= error
579
+ }
580
+ }
480
581
  errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
481
582
  if (activity !== undefined) {
482
583
  activity.recordTerminal(/** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), terminalState(terminalExitCode), terminalExitCode)
@@ -486,6 +587,7 @@ export async function main(arguments_, dependencies = {}) {
486
587
  launchDeadline?.close()
487
588
  }
488
589
  if (evidenceError !== undefined) throw evidenceError
590
+ if (deliveryError !== undefined) throw deliveryError
489
591
  return terminalExitCode
490
592
  } catch (error) {
491
593
  let reportedError = error
@@ -507,6 +609,44 @@ export async function main(arguments_, dependencies = {}) {
507
609
  }
508
610
  }
509
611
 
612
+ /** @param {string} value @returns {{type: "telegram", target: import("./types.js").TelegramTarget} | {type: "file", path: string}} */
613
+ function parseDeliveryTarget(value) {
614
+ if (!value.startsWith("file:")) return {type: "telegram", target: parseTelegramTarget(value)}
615
+ const path = value.slice("file:".length)
616
+ if (!isAbsolute(path) || normalize(path) !== path || path.includes("\0") || path === "/") {
617
+ throw new Error("File target must contain a normalized absolute file path")
618
+ }
619
+ return {type: "file", path}
620
+ }
621
+
622
+ /** @param {string} path @param {Set<string>} [seen] @returns {Promise<string>} */
623
+ async function canonicalFilesystemPath(path, seen = new Set()) {
624
+ if (seen.has(path)) throw new Error("Result or activity log path contains a filesystem alias cycle")
625
+ seen.add(path)
626
+ try {
627
+ return await realpath(path)
628
+ } catch (error) {
629
+ if (!missingPath(error)) throw error
630
+ }
631
+ try {
632
+ const metadata = await lstat(path)
633
+ if (metadata.isSymbolicLink()) {
634
+ return canonicalFilesystemPath(resolve(dirname(path), await readlink(path)), seen)
635
+ }
636
+ } catch (error) {
637
+ if (!missingPath(error)) throw error
638
+ }
639
+ const parent = dirname(path)
640
+ if (parent === path) return path
641
+ return join(await canonicalFilesystemPath(parent, seen), basename(path))
642
+ }
643
+
644
+ /** @param {unknown} error */
645
+ function missingPath(error) {
646
+ return typeof error === "object" && error !== null
647
+ && /** @type {{code?: unknown}} */ (error).code === "ENOENT"
648
+ }
649
+
510
650
  /** @param {string | undefined} value */
511
651
  function evidenceRoot(value) {
512
652
  if (value === undefined) return undefined