threadwire 0.1.14 → 0.1.17
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 +11 -0
- package/README.md +9 -7
- 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/scripts/verify-package.js +1 -0
- package/src/activity-log-status.js +291 -0
- package/src/activity-log.js +91 -3
- package/src/cli.js +198 -37
- package/src/delegated-result-admission.js +13 -0
- package/src/evidence-store.js +93 -23
- package/src/provider-capacity-codex.js +2 -1
- package/src/providers/executable.js +17 -4
- package/src/providers/index.js +109 -5
- package/src/telegram-ingress/core.js +180 -61
- package/src/worker-control.js +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.17 - 2026-08-06
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## 0.1.16 - 2026-08-04
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
## 0.1.15 - 2026-08-03
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
5
16
|
- Incrementally spool oversized provider JSONL records to private per-record temporary files after the bounded in-memory threshold, preserving byte order, UTF-8 chunk boundaries, malformed/incomplete-record diagnostics, and the exact absolute record cap while cleaning every per-record spill directory on success, failure, and cancellation. Protocol completion stops same-chunk scanning before any trailing record can exceed the cap or emit output. Ordinary valid provider records are no longer constrained to Telegram display sizes. Container-backed cancellation and failure teardown now retain exact Docker ownership through inspect, bounded TERM stop, terminal-state confirmation, and exact-container KILL escalation; CLI and Telegram deadline cancellation close the request side, await bounded terminal cleanup confirmation, and never treat an HTTP abort as cleanup success. Unconfirmed cleanup remains sealed for reconciliation and can never report success. Direct local-process behavior is unchanged.
|
|
6
17
|
- Extend the checked-in development image with build-time `oc-codex-multi-auth`, DeepSeek OpenAI-compatible, and xAI provider runtimes plus a root-owned secret-free OpenCode catalog for GPT-5.6 Sol/Terra/Luna, DeepSeek V4 Pro/Flash, Grok 4.5/Build/Code Fast, and Kimi K3 through OpenCode's built-in `moonshotai` provider. Kimi K3 keeps its 1,048,576-token context, 131,072-token output limit, default `max` reasoning effort, and `low`/`high`/`max` variants. Compose now requires portable source paths for native Codex auth, OpenCode auth, plugin OAuth, and the plugin's global account pool; refreshable files mount narrowly read-write, GitHub/context mounts stay read-only, per-project Codex account copies are disabled, and OpenCode database/session/cache state remains in the dedicated development home. The UID/GID 1000 initializer creates every nested mount target without receiving credentials. Threadwire remains the worker boundary; direct OpenCode is for authentication, discovery, and bounded smoke checks only.
|
|
7
18
|
|
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
|
|
@@ -68,7 +68,7 @@ Use the local launcher. Install or refresh it after updating Threadwire with `np
|
|
|
68
68
|
-- --model example-model
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
The target
|
|
71
|
+
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
72
|
|
|
73
73
|
`--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
74
|
|
|
@@ -84,15 +84,17 @@ The relay contract: the caller supplies `THREADWIRE_TARGET` (`telegram:<chat-id>
|
|
|
84
84
|
|
|
85
85
|
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
86
|
|
|
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.
|
|
87
|
+
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
88
|
|
|
89
|
-
`--activity-log <path>` is different: it retains its append contract and writes
|
|
89
|
+
`--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
|
+
|
|
91
|
+
Inspect a live or completed activity log with the read-only `threadwire status --activity-log <absolute-path>` command. It emits exactly one closed versioned JSON document with `version`, `state` (`running`/`retrying`/`blocked`/`completed`/`failed`/`cancelled`/`unknown`), `provider`, `controllerPid`, `providerPid`, `continuationHandle`, `startedAt`, `lastActivityAt`, `quietForMs`, `health` (or `null`), and `terminal` (or `null`). The command derives state from the file only, never opens it writable, never inspects or signals processes, and never claims stalled/dead from silence. PIDs are recorded facts with explicit non-authoritative semantics.
|
|
90
92
|
|
|
91
93
|
Provider stdout is decoded as JSONL with a finite 1 MiB UTF-8 byte limit for each pending unterminated record. Exceeding it rejects the run, removes listeners and signal handlers, and terminates the provider without parsing or rendering the oversized content.
|
|
92
94
|
|
|
93
95
|
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.
|
|
94
96
|
|
|
95
|
-
Scoped
|
|
97
|
+
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.
|
|
96
98
|
|
|
97
99
|
`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.
|
|
98
100
|
|
|
@@ -102,9 +104,9 @@ The Telegram bot token is used only by Threadwire's notifier and both `TELEGRAM_
|
|
|
102
104
|
|
|
103
105
|
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.
|
|
104
106
|
|
|
105
|
-
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.
|
|
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. A provider result is admitted independently: exhausted delivery retries return a delivery failure without rerunning the provider or changing its completed normalized result.
|
|
106
108
|
|
|
107
|
-
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.
|
|
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. 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.
|
|
108
110
|
|
|
109
111
|
## Development
|
|
110
112
|
|
|
@@ -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
|
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {isAbsolute, resolve} from "node:path"
|
|
4
|
+
import {open} from "node:fs/promises"
|
|
5
|
+
|
|
6
|
+
export const ACTIVITY_LOG_READ_CAPACITY = 1_048_576
|
|
7
|
+
|
|
8
|
+
const KNOWN_TYPES = new Set([
|
|
9
|
+
"provider-started", "controller-started", "session-available", "activity", "health", "terminal"
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u
|
|
13
|
+
|
|
14
|
+
const VALIDATED_FACT_FIELDS = {
|
|
15
|
+
"provider-started": ["type", "provider", "pid"],
|
|
16
|
+
"controller-started": ["type", "pid"],
|
|
17
|
+
"session-available": ["type", "provider", "sessionId"],
|
|
18
|
+
"activity": ["type", "provider", "kind"],
|
|
19
|
+
"health": ["type", "provider", "disposition", "category"],
|
|
20
|
+
"terminal": ["type", "provider", "state", "exitCode"]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const OPTIONAL_FACT_FIELDS = {
|
|
24
|
+
"provider-started": ["at"],
|
|
25
|
+
"controller-started": ["at"],
|
|
26
|
+
"session-available": ["at"],
|
|
27
|
+
"activity": ["at"],
|
|
28
|
+
"health": ["retryAfterMs", "at"],
|
|
29
|
+
"terminal": ["at"]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const PROVIDERS = new Set(["codex", "claude", "kimi", "opencode"])
|
|
33
|
+
const ACTIVITY_KINDS = new Set(["delta", "tool", "lifecycle"])
|
|
34
|
+
const HEALTH_DISPOSITIONS = new Set(["retrying", "blocked"])
|
|
35
|
+
const HEALTH_CATEGORIES = new Set([
|
|
36
|
+
"authentication", "permission", "rate-limit", "quota", "billing",
|
|
37
|
+
"model", "network", "protocol", "unknown"
|
|
38
|
+
])
|
|
39
|
+
const TERMINAL_STATES = new Set(["completed", "failed", "cancelled"])
|
|
40
|
+
|
|
41
|
+
const MAX_SAFE_DATE_MS = 8640000000000000 // Date maximum
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {{version: number, state: "running" | "retrying" | "blocked" | "completed" | "failed" | "cancelled" | "unknown", provider: string | null, controllerPid: number | null, providerPid: number | null, continuationHandle: string | null, startedAt: string | null, lastActivityAt: string | null, quietForMs: number, health: {disposition: "retrying" | "blocked", category: string, retryAfterMs?: number} | null, terminal: {state: "completed" | "failed" | "cancelled", exitCode: number} | null}} StatusDocument
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** @param {string[]} arguments_ */
|
|
48
|
+
export function parseStatusArguments(arguments_) {
|
|
49
|
+
if (arguments_[0] !== "status") throw new Error("Usage: threadwire status --activity-log <absolute-path>")
|
|
50
|
+
const own = arguments_.slice(1)
|
|
51
|
+
if (own.length !== 2 || own[0] !== "--activity-log") throw new Error("Usage: threadwire status --activity-log <absolute-path>")
|
|
52
|
+
const path = own[1]
|
|
53
|
+
if (!path) throw new Error("--activity-log must be an absolute path")
|
|
54
|
+
const resolved = resolve(path)
|
|
55
|
+
if (!isAbsolute(path)) throw new Error("--activity-log must be an absolute path")
|
|
56
|
+
return {activityLog: resolved}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read and validate an activity log file, with a bounded read capacity to
|
|
61
|
+
* prevent uncontrolled allocation. Skips at most one incomplete trailing line.
|
|
62
|
+
* Rejects malformed completed records and records with unsafe field names.
|
|
63
|
+
* @param {string} path
|
|
64
|
+
* @returns {Promise<Record<string, unknown>[]>}
|
|
65
|
+
*/
|
|
66
|
+
export async function readActivityLog(path) {
|
|
67
|
+
const fd = await open(path, "r")
|
|
68
|
+
try {
|
|
69
|
+
const stat = await fd.stat()
|
|
70
|
+
if (stat.size > ACTIVITY_LOG_READ_CAPACITY) throw new Error("Activity log exceeds read capacity")
|
|
71
|
+
const buffer = Buffer.alloc(stat.size > 0 ? stat.size : 0)
|
|
72
|
+
let bytesRead = 0
|
|
73
|
+
while (bytesRead < stat.size) {
|
|
74
|
+
const result = await fd.read(buffer, bytesRead, stat.size - bytesRead, bytesRead)
|
|
75
|
+
if (result.bytesRead === 0) break
|
|
76
|
+
bytesRead += result.bytesRead
|
|
77
|
+
}
|
|
78
|
+
return parseActivityLogContent(buffer.toString("utf8", 0, bytesRead))
|
|
79
|
+
} finally {
|
|
80
|
+
await fd.close()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} content
|
|
86
|
+
* @returns {Record<string, unknown>[]}
|
|
87
|
+
*/
|
|
88
|
+
function parseActivityLogContent(content) {
|
|
89
|
+
const lines = content.split("\n")
|
|
90
|
+
/** @type {Record<string, unknown>[]} */
|
|
91
|
+
const records = []
|
|
92
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
93
|
+
const line = /** @type {string} */ (lines[index])
|
|
94
|
+
if (line.trim().length === 0) continue
|
|
95
|
+
let record
|
|
96
|
+
try {
|
|
97
|
+
record = JSON.parse(line)
|
|
98
|
+
} catch {
|
|
99
|
+
if (index === lines.length - 1) break
|
|
100
|
+
throw new Error("Activity log contains an unreadable completed record")
|
|
101
|
+
}
|
|
102
|
+
if (!isRecord(record)) throw new Error("Activity log record is not an object")
|
|
103
|
+
validateRecordSchema(record)
|
|
104
|
+
records.push(record)
|
|
105
|
+
}
|
|
106
|
+
return records
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pure computation: derives a closed status document from an array of
|
|
111
|
+
* validated activity-log facts.
|
|
112
|
+
* @param {Record<string, unknown>[]} records
|
|
113
|
+
* @param {{now?: () => number}} [options]
|
|
114
|
+
* @returns {StatusDocument}
|
|
115
|
+
*/
|
|
116
|
+
export function computeStatus(records, options = {}) {
|
|
117
|
+
const now = options.now ?? (() => Date.now())
|
|
118
|
+
const clock = now()
|
|
119
|
+
if (!Number.isSafeInteger(clock) || clock < 0 || clock > MAX_SAFE_DATE_MS) throw new Error("Clock must be a safe non-negative Date-range integer")
|
|
120
|
+
|
|
121
|
+
let providerStarted = null
|
|
122
|
+
/** @type {number | null} */
|
|
123
|
+
let controllerPid = null
|
|
124
|
+
/** @type {string | null} */
|
|
125
|
+
let continuationHandle = null
|
|
126
|
+
/** @type {{disposition: "retrying" | "blocked", category: string, retryAfterMs?: number} | null} */
|
|
127
|
+
let health = null
|
|
128
|
+
/** @type {{state: "completed" | "failed" | "cancelled", exitCode: number} | null} */
|
|
129
|
+
let terminal = null
|
|
130
|
+
let sawTerminal = false
|
|
131
|
+
/** @type {number | null} */
|
|
132
|
+
let startedAt = null
|
|
133
|
+
/** @type {number | null} */
|
|
134
|
+
let lastActivityAt = null
|
|
135
|
+
let lastHealthIndex = -1
|
|
136
|
+
let lastActivityIndex = -1
|
|
137
|
+
|
|
138
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
139
|
+
const record = /** @type {Record<string, unknown>} */ (records[index])
|
|
140
|
+
// Validate timestamps on every record, since computeStatus may be
|
|
141
|
+
// called directly (not through readActivityLog).
|
|
142
|
+
const atValue = record.at
|
|
143
|
+
if (atValue !== undefined && (typeof atValue !== "number" || !Number.isSafeInteger(atValue) || atValue < 0 || atValue > MAX_SAFE_DATE_MS)) {
|
|
144
|
+
throw new Error("Invalid timestamp")
|
|
145
|
+
}
|
|
146
|
+
const type = record.type
|
|
147
|
+
if (typeof type !== "string" || !KNOWN_TYPES.has(type)) {
|
|
148
|
+
throw new Error(`Unknown activity log record type: ${String(type)}`)
|
|
149
|
+
}
|
|
150
|
+
if (type === "provider-started") {
|
|
151
|
+
if (providerStarted !== null) throw new Error("Duplicate provider-started record")
|
|
152
|
+
providerStarted = record
|
|
153
|
+
if (typeof atValue === "number" && startedAt === null) startedAt = atValue
|
|
154
|
+
else lastActivityAt = lastActivityAt ?? null
|
|
155
|
+
} else if (type === "controller-started") {
|
|
156
|
+
const pid = record.pid
|
|
157
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) {
|
|
158
|
+
throw new Error("Invalid controller PID in activity log")
|
|
159
|
+
}
|
|
160
|
+
controllerPid = pid
|
|
161
|
+
if (typeof atValue === "number" && startedAt === null) startedAt = atValue
|
|
162
|
+
if (typeof atValue === "number") lastActivityAt = atValue
|
|
163
|
+
} else if (type === "session-available") {
|
|
164
|
+
const id = typeof record.sessionId === "string" ? record.sessionId : null
|
|
165
|
+
if (id !== null && !SESSION_ID_PATTERN.test(id)) throw new Error("Invalid continuation handle in activity log")
|
|
166
|
+
continuationHandle = id
|
|
167
|
+
if (typeof atValue === "number") lastActivityAt = atValue
|
|
168
|
+
} else if (type === "activity") {
|
|
169
|
+
lastActivityIndex = index
|
|
170
|
+
if (typeof atValue === "number") lastActivityAt = atValue
|
|
171
|
+
} else if (type === "health") {
|
|
172
|
+
if (sawTerminal) throw new Error("Health record appears after terminal")
|
|
173
|
+
lastHealthIndex = index
|
|
174
|
+
health = {
|
|
175
|
+
disposition: /** @type {"retrying" | "blocked"} */ (record.disposition),
|
|
176
|
+
category: /** @type {string} */ (record.category)
|
|
177
|
+
}
|
|
178
|
+
if (typeof record.retryAfterMs === "number") health.retryAfterMs = record.retryAfterMs
|
|
179
|
+
if (typeof atValue === "number") lastActivityAt = atValue
|
|
180
|
+
// Blocked stays sticky regardless; terminal always overrides later.
|
|
181
|
+
} else if (type === "terminal") {
|
|
182
|
+
if (sawTerminal) throw new Error("Duplicate terminal record")
|
|
183
|
+
sawTerminal = true
|
|
184
|
+
terminal = {
|
|
185
|
+
state: /** @type {"completed" | "failed" | "cancelled"} */ (record.state),
|
|
186
|
+
exitCode: /** @type {number} */ (record.exitCode)
|
|
187
|
+
}
|
|
188
|
+
if (typeof atValue === "number") lastActivityAt = atValue
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Activity that appears after a retrying health event clears it back to running.
|
|
193
|
+
// Blocked health stays sticky until terminal overrides it.
|
|
194
|
+
if (health !== null && health.disposition === "retrying" && lastActivityIndex > lastHealthIndex) {
|
|
195
|
+
health = null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @type {number | null} */
|
|
199
|
+
let effectiveStart = startedAt
|
|
200
|
+
const ps = providerStarted
|
|
201
|
+
if (effectiveStart === null && ps !== null && typeof ps.at === "number") {
|
|
202
|
+
effectiveStart = /** @type {number} */ (ps.at)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const state = terminal !== null
|
|
206
|
+
? terminal.state
|
|
207
|
+
: health !== null
|
|
208
|
+
? (health.disposition === "blocked" ? "blocked" : "retrying")
|
|
209
|
+
: ps !== null
|
|
210
|
+
? "running"
|
|
211
|
+
: "unknown"
|
|
212
|
+
|
|
213
|
+
const quietForMs = lastActivityAt === null || lastActivityAt === undefined
|
|
214
|
+
? (effectiveStart === null ? 0 : Math.max(0, clock - effectiveStart))
|
|
215
|
+
: Math.max(0, clock - lastActivityAt)
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
version: 1,
|
|
219
|
+
state,
|
|
220
|
+
provider: ps !== null && typeof ps.provider === "string"
|
|
221
|
+
? ps.provider
|
|
222
|
+
: null,
|
|
223
|
+
controllerPid,
|
|
224
|
+
providerPid: ps !== null && typeof ps.pid === "number"
|
|
225
|
+
? /** @type {number} */ (ps.pid)
|
|
226
|
+
: null,
|
|
227
|
+
continuationHandle,
|
|
228
|
+
startedAt: effectiveStart === null ? null : new Date(effectiveStart).toISOString(),
|
|
229
|
+
lastActivityAt: lastActivityAt === null ? null : new Date(lastActivityAt).toISOString(),
|
|
230
|
+
quietForMs,
|
|
231
|
+
health,
|
|
232
|
+
terminal
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Validate a single activity-log record against the closed schema.
|
|
238
|
+
* @param {Record<string, unknown>} record
|
|
239
|
+
*/
|
|
240
|
+
function validateRecordSchema(record) {
|
|
241
|
+
const type = record.type
|
|
242
|
+
if (typeof type !== "string") throw new Error("Activity log record missing type")
|
|
243
|
+
const requiredFields = VALIDATED_FACT_FIELDS[/** @type {keyof typeof VALIDATED_FACT_FIELDS} */ (type)]
|
|
244
|
+
if (requiredFields === undefined) throw new Error(`Unknown activity log record type: ${type}`)
|
|
245
|
+
const optionalFields = OPTIONAL_FACT_FIELDS[/** @type {keyof typeof OPTIONAL_FACT_FIELDS} */ (type)] ?? []
|
|
246
|
+
const allowed = new Set([...requiredFields, ...optionalFields])
|
|
247
|
+
|
|
248
|
+
const actual = Object.keys(record)
|
|
249
|
+
for (const field of actual) {
|
|
250
|
+
if (!allowed.has(field)) throw new Error(`Unexpected field in ${type} record: ${field}`)
|
|
251
|
+
}
|
|
252
|
+
for (const field of requiredFields) {
|
|
253
|
+
if (!(field in record)) throw new Error(`Missing required field in ${type} record: ${field}`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (type === "provider-started") {
|
|
257
|
+
if (typeof record.provider !== "string" || !PROVIDERS.has(record.provider)) throw new Error("Invalid provider")
|
|
258
|
+
if (typeof record.pid !== "number" || !Number.isSafeInteger(record.pid) || record.pid <= 0) throw new Error("Invalid pid")
|
|
259
|
+
} else if (type === "controller-started") {
|
|
260
|
+
if (typeof record.pid !== "number" || !Number.isSafeInteger(record.pid) || record.pid <= 0) throw new Error("Invalid pid")
|
|
261
|
+
} else if (type === "session-available") {
|
|
262
|
+
if (typeof record.provider !== "string" || !PROVIDERS.has(record.provider)) throw new Error("Invalid provider")
|
|
263
|
+
if (typeof record.sessionId !== "string") throw new Error("Invalid sessionId")
|
|
264
|
+
} else if (type === "activity") {
|
|
265
|
+
if (typeof record.provider !== "string" || !PROVIDERS.has(record.provider)) throw new Error("Invalid provider")
|
|
266
|
+
if (typeof record.kind !== "string" || !ACTIVITY_KINDS.has(record.kind)) throw new Error("Invalid activity kind")
|
|
267
|
+
} else if (type === "health") {
|
|
268
|
+
if (typeof record.provider !== "string" || !PROVIDERS.has(record.provider)) throw new Error("Invalid provider")
|
|
269
|
+
if (typeof record.disposition !== "string" || !HEALTH_DISPOSITIONS.has(record.disposition)) throw new Error("Invalid health disposition")
|
|
270
|
+
if (typeof record.category !== "string" || !HEALTH_CATEGORIES.has(record.category)) throw new Error("Invalid health category")
|
|
271
|
+
if (record.retryAfterMs !== undefined) {
|
|
272
|
+
if (typeof record.retryAfterMs !== "number" || !Number.isSafeInteger(record.retryAfterMs) || record.retryAfterMs <= 0) {
|
|
273
|
+
throw new Error("Invalid retryAfterMs")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} else if (type === "terminal") {
|
|
277
|
+
if (typeof record.provider !== "string" || !PROVIDERS.has(record.provider)) throw new Error("Invalid provider")
|
|
278
|
+
if (typeof record.state !== "string" || !TERMINAL_STATES.has(record.state)) throw new Error("Invalid terminal state")
|
|
279
|
+
if (typeof record.exitCode !== "number" || !Number.isSafeInteger(record.exitCode) || record.exitCode < 0 || record.exitCode > 255) {
|
|
280
|
+
throw new Error("Invalid exitCode")
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (record.at !== undefined && (typeof record.at !== "number" || !Number.isSafeInteger(record.at) || record.at < 0 || record.at > MAX_SAFE_DATE_MS)) {
|
|
284
|
+
throw new Error("Invalid timestamp")
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
289
|
+
function isRecord(value) {
|
|
290
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
291
|
+
}
|