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 +4 -0
- package/README.md +25 -12
- package/docs/capacity-admission.md +10 -4
- package/package.json +1 -1
- package/src/cli.js +2 -1
- package/src/provider-capacity-codex.js +3 -3
- package/src/providers/codex.js +10 -6
- package/src/providers/executable.js +76 -1
- package/src/providers/index.js +3 -3
- package/src/telegram-ingress/core.js +1 -1
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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.
|
package/package.json
CHANGED
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 {
|
|
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 ??
|
|
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
|
}
|
package/src/providers/codex.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {codexExecutable} from "./executable.js"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/providers/index.js
CHANGED
|
@@ -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({
|