threadwire 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
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
+
5
9
  ## 0.1.17 - 2026-08-06
6
10
 
7
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.
package/README.md CHANGED
@@ -30,21 +30,29 @@ installation:
30
30
  ```sh
31
31
  npx --yes threadwire@latest --help
32
32
 
33
- THREADWIRE_CODEX_BIN=/path/to/direct/codex \
34
- npx --yes threadwire@latest run \
35
- --provider codex \
36
- --target telegram:-1001234567890:42 \
37
- --cwd /path/to/repository \
38
- --prompt 'Inspect the project.'
33
+ npx --yes threadwire@latest run \
34
+ --provider codex \
35
+ --target telegram:-1001234567890:42 \
36
+ --cwd /path/to/repository \
37
+ --prompt 'Inspect the project.'
39
38
  ```
40
39
 
41
40
  Provide `THREADWIRE_TELEGRAM_BOT_TOKEN` through a trusted environment or secret
42
- manager; do not put the token in the command line. For Claude or OpenCode, use
43
- `THREADWIRE_CLAUDE_BIN` or `THREADWIRE_OPENCODE_BIN` respectively. The override
44
- must name the direct provider executable, never a Threadwire front-door shim,
45
- so the relay cannot recursively launch itself. Existing Threadwire hosts may
46
- omit the override when their reviewed `/opt/data/libexec/threadwire/*` adapters
47
- are installed.
41
+ manager; do not put the token in the command line. Codex first uses a valid
42
+ `THREADWIRE_CODEX_BIN`, then the executable structural adapter at
43
+ `/opt/data/libexec/threadwire/codex`, then the first executable `codex` found by
44
+ left-to-right Unix lookup in the supplied `PATH`. Relative and empty PATH
45
+ entries resolve from the worker's `--cwd`. Candidates are canonically resolved
46
+ and inspected before use; known Threadwire front doors such as
47
+ `/opt/data/bin/codex`, including aliases to them, are rejected while lookup
48
+ continues, preventing recursive relay. A stale override warns with the actual
49
+ selected fallback, and no safe candidate retains the explicit structural path
50
+ for the normal unavailable failure rather than spawning an unchecked bare
51
+ command. Use
52
+ `THREADWIRE_CODEX_BIN` only for an intentional custom/container adapter. For
53
+ Claude or OpenCode, use `THREADWIRE_CLAUDE_BIN` or `THREADWIRE_OPENCODE_BIN`
54
+ respectively; those overrides must name direct provider executables, never
55
+ Threadwire front-door shims.
48
56
 
49
57
  To install the same commands globally instead:
50
58
 
@@ -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.
@@ -51,10 +51,16 @@ pin before relying on it.
51
51
 
52
52
  - **Codex** spawns `codex app-server` and runs a bounded two-request JSON-RPC
53
53
  exchange over stdio: `initialize`, then `account/rateLimits/read`. The
54
- executable resolves via `THREADWIRE_CODEX_BIN` or the default libexec
55
- adapter, exactly like the Codex worker provider; the anti-recursion default
56
- is unchanged, so native container callers point the existing safe override
57
- at the real CLI (for example `THREADWIRE_CODEX_BIN=/usr/local/bin/codex`).
54
+ executable resolves exactly like the Codex worker provider: a valid
55
+ `THREADWIRE_CODEX_BIN`, the executable structural
56
+ `/opt/data/libexec/threadwire/codex` adapter, then the first executable
57
+ `codex` found by left-to-right Unix lookup in the supplied `PATH`. Each PATH
58
+ candidate is canonically resolved and inspected before use; relative and
59
+ empty entries resolve from the probe's working directory. Known Threadwire
60
+ front doors, notably `/opt/data/bin/codex` and aliases to it, are skipped
61
+ while lookup continues; if no safe candidate exists, the probe reports
62
+ unavailable without spawning an unchecked bare command. A stale explicit
63
+ override warns with the selected fallback.
58
64
  Only the quota projection of the result is parsed: `rateLimits.primary` maps
59
65
  to `short` and `rateLimits.secondary` to `long`; a `null` secondary yields a
60
66
  short-only snapshot, and a secondary shorter than the primary fails closed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/cli.js CHANGED
@@ -415,7 +415,8 @@ export async function main(arguments_, dependencies = {}) {
415
415
  parsed.providerArguments,
416
416
  prompt,
417
417
  parsed.resumeSession,
418
- providerEnvironment
418
+ providerEnvironment,
419
+ {cwd: parsed.cwd}
419
420
  )
420
421
  if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
421
422
  if (destination.type === "telegram") {
@@ -1,10 +1,9 @@
1
1
  // @ts-check
2
2
 
3
3
  import {spawn} from "node:child_process"
4
- import {providerExecutable} from "./providers/executable.js"
4
+ import {codexExecutable} from "./providers/executable.js"
5
5
  import {CapacityProbeError, DEFAULT_CAPACITY_TIMEOUT_MS, normalizeCodexRateLimits} from "./provider-capacity.js"
6
6
 
7
- const DEFAULT_EXECUTABLE = "/opt/data/libexec/threadwire/codex"
8
7
  const MAX_LINE_BYTES = 65_536
9
8
  const MAX_LINES = 1_024
10
9
 
@@ -17,6 +16,7 @@ const MAX_LINES = 1_024
17
16
  * @param {{
18
17
  * env?: NodeJS.ProcessEnv,
19
18
  * executable?: string | undefined,
19
+ * executableResolution?: Parameters<typeof codexExecutable>[1],
20
20
  * spawnImplementation?: typeof spawn | undefined,
21
21
  * timeoutMs?: number
22
22
  * }} options
@@ -26,7 +26,7 @@ export async function probeCodexCapacity(options) {
26
26
  const environment = options.env ?? process.env
27
27
  const spawnImplementation = options.spawnImplementation ?? spawn
28
28
  const timeoutMs = options.timeoutMs ?? DEFAULT_CAPACITY_TIMEOUT_MS
29
- const executable = options.executable ?? providerExecutable("THREADWIRE_CODEX_BIN", DEFAULT_EXECUTABLE, environment)
29
+ const executable = options.executable ?? codexExecutable(environment, options.executableResolution)
30
30
  const result = await appServerExchange(spawnImplementation, executable, environment, timeoutMs)
31
31
  return normalizeCodexRateLimits(result)
32
32
  }
@@ -1,16 +1,20 @@
1
1
  // @ts-check
2
2
 
3
- import {providerExecutable} from "./executable.js"
3
+ import {codexExecutable} from "./executable.js"
4
4
 
5
- const DEFAULT_EXECUTABLE = "/opt/data/libexec/threadwire/codex"
6
-
7
- /** @param {string[]} providerArguments @param {string} prompt @param {string | undefined} [resumeSession] */
8
- export function buildCodexCommand(providerArguments, prompt, resumeSession, environment = process.env) {
5
+ /**
6
+ * @param {string[]} providerArguments
7
+ * @param {string} prompt
8
+ * @param {string | undefined} [resumeSession]
9
+ * @param {NodeJS.ProcessEnv} [environment]
10
+ * @param {Parameters<typeof codexExecutable>[1]} [executableResolution]
11
+ */
12
+ export function buildCodexCommand(providerArguments, prompt, resumeSession, environment = process.env, executableResolution = {}) {
9
13
  rejectOwnedArguments(providerArguments)
10
14
  const arguments_ = resumeSession === undefined
11
15
  ? ["exec", "--json", ...providerArguments, prompt]
12
16
  : ["exec", "resume", "--json", ...providerArguments, resumeSession, prompt]
13
- return {executable: providerExecutable("THREADWIRE_CODEX_BIN", DEFAULT_EXECUTABLE, environment), arguments: arguments_}
17
+ return {executable: codexExecutable(environment, executableResolution), arguments: arguments_}
14
18
  }
15
19
 
16
20
  /** @param {string[]} arguments_ */
@@ -1,6 +1,13 @@
1
1
  // @ts-check
2
2
 
3
- import {accessSync, constants, statSync} from "node:fs"
3
+ import {accessSync, constants, realpathSync, statSync} from "node:fs"
4
+ import {resolve} from "node:path"
5
+
6
+ const CODEX_STRUCTURAL_EXECUTABLE = "/opt/data/libexec/threadwire/codex"
7
+ const CODEX_FRONT_DOOR_EXECUTABLES = new Set(["/opt/data/bin/codex"])
8
+
9
+ /** @typedef {(path: string) => boolean} ExecutableProbe */
10
+ /** @typedef {{canonicalize?: ((path: string) => string) | undefined, cwd?: string | undefined, isExecutable?: ExecutableProbe | undefined, warn?: ((warning: string) => void) | undefined}} ExecutableResolutionOptions */
4
11
 
5
12
  /**
6
13
  * Resolve the terminal provider executable Threadwire spawns. The default is the
@@ -27,3 +34,71 @@ export function providerExecutable(overrideVariable, defaultPath, environment =
27
34
  }
28
35
  return defaultPath
29
36
  }
37
+
38
+ /**
39
+ * Resolve Codex without consulting a bare command through spawn. A valid
40
+ * override wins, followed by the structural adapter and then executable PATH
41
+ * candidates in Unix lookup order. Known Threadwire front doors are skipped so
42
+ * a normal direct-CLI lookup cannot recurse into Threadwire.
43
+ * @param {NodeJS.ProcessEnv} [environment]
44
+ * @param {ExecutableResolutionOptions} [options]
45
+ * @returns {string}
46
+ */
47
+ export function codexExecutable(environment = process.env, options = {}) {
48
+ const isExecutable = options.isExecutable ?? executableFile
49
+ const warning = options.warn ?? console.error
50
+ const canonicalize = options.canonicalize ?? realpathSync
51
+ const cwd = options.cwd ?? process.cwd()
52
+ const override = environment.THREADWIRE_CODEX_BIN
53
+ if (override !== undefined && override.length > 0 && isExecutable(override)) return override
54
+
55
+ const fallback = discoveredCodexExecutable(environment, cwd, isExecutable, canonicalize) ?? CODEX_STRUCTURAL_EXECUTABLE
56
+ if (override !== undefined && override.length > 0) {
57
+ warning(`threadwire: THREADWIRE_CODEX_BIN=${override} is not executable, falling back to ${fallback}`)
58
+ }
59
+ return fallback
60
+ }
61
+
62
+ /**
63
+ * @param {NodeJS.ProcessEnv} environment
64
+ * @param {string} cwd
65
+ * @param {ExecutableProbe} isExecutable
66
+ * @param {(path: string) => string} canonicalize
67
+ * @returns {string | undefined}
68
+ */
69
+ function discoveredCodexExecutable(environment, cwd, isExecutable, canonicalize) {
70
+ if (isExecutable(CODEX_STRUCTURAL_EXECUTABLE)) return CODEX_STRUCTURAL_EXECUTABLE
71
+ if (environment.PATH === undefined) return undefined
72
+ for (const directory of environment.PATH.split(":")) {
73
+ const candidate = resolve(cwd, directory, "codex")
74
+ if (CODEX_FRONT_DOOR_EXECUTABLES.has(candidate)) continue
75
+ const canonicalPath = canonicalExecutable(candidate, isExecutable, canonicalize)
76
+ if (canonicalPath !== undefined && !CODEX_FRONT_DOOR_EXECUTABLES.has(canonicalPath)) return candidate
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /**
82
+ * @param {string} path
83
+ * @param {ExecutableProbe} isExecutable
84
+ * @param {(path: string) => string} canonicalize
85
+ * @returns {string | undefined}
86
+ */
87
+ function canonicalExecutable(path, isExecutable, canonicalize) {
88
+ if (!isExecutable(path)) return undefined
89
+ try {
90
+ return canonicalize(path)
91
+ } catch {
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ /** @type {ExecutableProbe} */
97
+ function executableFile(path) {
98
+ try {
99
+ accessSync(path, constants.X_OK)
100
+ return statSync(path).isFile()
101
+ } catch {
102
+ return false
103
+ }
104
+ }
@@ -13,10 +13,10 @@ import {buildOpenCodeCommand, createOpenCodeParser, createOpenCodeSessionId} fro
13
13
  /** @type {readonly ["codex", "claude", "kimi", "opencode"]} */
14
14
  export const PROVIDERS = ["codex", "claude", "kimi", "opencode"]
15
15
 
16
- /** @param {string} name @param {string[]} providerArguments @param {string} prompt @param {string | undefined} resumeSession @param {NodeJS.ProcessEnv} [environment] @returns {Provider} */
17
- export function createProvider(name, providerArguments, prompt, resumeSession, environment = process.env) {
16
+ /** @param {string} name @param {string[]} providerArguments @param {string} prompt @param {string | undefined} resumeSession @param {NodeJS.ProcessEnv} [environment] @param {Parameters<typeof buildCodexCommand>[4]} [codexExecutableResolution] @returns {Provider} */
17
+ export function createProvider(name, providerArguments, prompt, resumeSession, environment = process.env, codexExecutableResolution = {}) {
18
18
  if (name === "codex") {
19
- const command = buildCodexCommand(providerArguments, prompt, resumeSession, environment)
19
+ const command = buildCodexCommand(providerArguments, prompt, resumeSession, environment, codexExecutableResolution)
20
20
  return {...command, name: "codex", parse: parseCodexEvent, sessionId: codexSessionId, health: extractProviderHealth}
21
21
  }
22
22
  if (name === "claude") {
@@ -149,7 +149,7 @@ export async function dispatchWorker(job, config, dependencies = {}) {
149
149
  const deliveryIdentity = randomUUID()
150
150
  const admission = new DelegatedResultAdmission({output: {write: () => {}}})
151
151
  try {
152
- provider = createProviderImpl(job.provider, [], job.prompt, undefined, providerEnvironment)
152
+ provider = createProviderImpl(job.provider, [], job.prompt, undefined, providerEnvironment, {cwd})
153
153
  const transport = createFetchTransportImpl(config.botToken, undefined, config.telegramRequestTimeoutMs)
154
154
  sender = createTelegramSenderImpl(job.target, transport)
155
155
  control = new WorkerControlImpl({