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 +12 -0
- package/README.md +31 -18
- package/docs/capacity-admission.md +10 -4
- package/docs/delegated-result-protocol.md +20 -0
- package/docs/evidence-artifacts.md +37 -5
- package/package.json +1 -1
- package/scripts/install-local-launcher.js +23 -1
- package/src/cli.js +175 -35
- package/src/delegated-result-admission.js +13 -0
- package/src/evidence-store.js +93 -23
- package/src/provider-capacity-codex.js +4 -3
- package/src/providers/codex.js +10 -6
- package/src/providers/executable.js +92 -4
- package/src/providers/index.js +3 -3
- package/src/telegram-ingress/core.js +181 -62
- package/src/worker-control.js +11 -0
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
and
|
|
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
|
@@ -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
|
-
|
|
309
|
-
|
|
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(
|
|
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
|
-
|
|
376
|
+
evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
|
|
350
377
|
if (evidenceStore !== undefined) {
|
|
351
|
-
|
|
352
|
-
destinationId:
|
|
353
|
-
|
|
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
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
476
|
-
evidenceError
|
|
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
|