@threahq/remote-session 0.1.0
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/LICENSE +21 -0
- package/README.md +219 -0
- package/attachments.d.ts +107 -0
- package/client.d.ts +146 -0
- package/config-file.d.ts +12 -0
- package/delegation-client.d.ts +98 -0
- package/delegation-runner.d.ts +57 -0
- package/examples/echo-connector.ts +73 -0
- package/examples/mention-bot.ts +120 -0
- package/identity.d.ts +157 -0
- package/index.d.ts +10 -0
- package/index.js +3683 -0
- package/index.js.map +19 -0
- package/lifecycle.d.ts +41 -0
- package/package.json +44 -0
- package/session.d.ts +729 -0
- package/session.test-support.d.ts +6 -0
- package/tool-trace.d.ts +14 -0
- package/turn-route.d.ts +149 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Threa contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# @threahq/remote-session
|
|
2
|
+
|
|
3
|
+
SDK for connecting an agent runtime to a Threa scratchpad. A connector
|
|
4
|
+
implements one hook, `deliverTurn`, and optionally a session-control actuator.
|
|
5
|
+
The SDK links the scratchpad, claims work, keeps presence and claim leases
|
|
6
|
+
alive, routes `/steer` and `/stop`, moves attachments in both directions, and
|
|
7
|
+
posts interim and final replies. Claude Code and Pi connect to Threa through
|
|
8
|
+
this package.
|
|
9
|
+
|
|
10
|
+
The protocol underneath is public and documented at
|
|
11
|
+
[threa.io/developers](https://threa.io/developers) (API reference sections
|
|
12
|
+
`Bot runtimes` and `Bot invocations`, and the "Connect your local agent"
|
|
13
|
+
recipe). This package is a client for it; nothing here needs a special server.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @threahq/remote-session
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Node 20+ or Bun. Pulls in `@threahq/bot-runtime-client` (the socket transport)
|
|
22
|
+
and `socket.io-client`.
|
|
23
|
+
|
|
24
|
+
## Which flow you need
|
|
25
|
+
|
|
26
|
+
Threa dispatches work to a bot in two ways, and the runtime kind you register
|
|
27
|
+
with picks the one you get.
|
|
28
|
+
|
|
29
|
+
Mention-driven. Someone `@mentions` the bot in any stream. Any live instance of
|
|
30
|
+
the bot may claim the invocation, do the work, and complete it with a reply.
|
|
31
|
+
Available to every runtime kind, including `custom`. This is `ThreaClient`
|
|
32
|
+
plus the transport; see [`examples/mention-bot.ts`](examples/mention-bot.ts).
|
|
33
|
+
|
|
34
|
+
Scratchpad-linked. The runtime owns a scratchpad: every message in it is a
|
|
35
|
+
turn for the runtime, the composer offers `/steer`, `/stop`, and whatever
|
|
36
|
+
commands the runtime advertises, and the runtime replies in place. This is
|
|
37
|
+
`RemoteSession`, registering as runtime kind `custom`. See
|
|
38
|
+
[`examples/echo-connector.ts`](examples/echo-connector.ts).
|
|
39
|
+
|
|
40
|
+
## Credentials
|
|
41
|
+
|
|
42
|
+
Create a bot in Threa (personal or workspace), give it the `mentionable` trait
|
|
43
|
+
and, for a scratchpad-linked runtime, `active-scratchpad`. Mint a
|
|
44
|
+
`threa_bk_` key on it with `bot-runtime:write`, `bot-invocations:write`,
|
|
45
|
+
`messages:write`, `streams:read`, `messages:read`, and `attachments:read`
|
|
46
|
+
(add `attachments:write` to send files back, `delegations:read` and
|
|
47
|
+
`delegations:write` to run delegations). `loadConfig` reads
|
|
48
|
+
`THREA_WORKSPACE_ID`, `THREA_API_KEY`, and optional `THREA_BASE_URL` (default
|
|
49
|
+
`https://app.threa.io`) from the environment, or from a JSON file you pass in.
|
|
50
|
+
|
|
51
|
+
## A connector
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { hostname } from "node:os"
|
|
55
|
+
import {
|
|
56
|
+
RemoteSession,
|
|
57
|
+
ThreaClient,
|
|
58
|
+
loadConfig,
|
|
59
|
+
wireLifecycle,
|
|
60
|
+
type SessionControlActuator,
|
|
61
|
+
} from "@threahq/remote-session"
|
|
62
|
+
|
|
63
|
+
const identity = { idPrefix: "oc", sessionIdPrefix: "ocs", displayNamePrefix: "OpenClaw" }
|
|
64
|
+
const result = loadConfig({ env: process.env, cwd: process.cwd(), hostname: hostname() }, identity)
|
|
65
|
+
if ("error" in result) throw new Error(result.error)
|
|
66
|
+
|
|
67
|
+
// Only when the connector can drive its runtime; omit it and Threa never
|
|
68
|
+
// offers the commands.
|
|
69
|
+
const sessionControl: SessionControlActuator = {
|
|
70
|
+
commands: ["stop", "steer", "model"],
|
|
71
|
+
interrupt: () => myRuntime.interrupt(),
|
|
72
|
+
runCommand: async (name, args) => ({ ok: true, message: await myRuntime.run(name, args) }),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const session = new RemoteSession({
|
|
76
|
+
config: result.config,
|
|
77
|
+
client: new ThreaClient(result.config),
|
|
78
|
+
runtime: {
|
|
79
|
+
kind: "custom",
|
|
80
|
+
busyStatusText: "Working in OpenClaw…",
|
|
81
|
+
forwardedNote: "Forwarded to OpenClaw.",
|
|
82
|
+
shutdownErrorMessage: "OpenClaw channel shut down",
|
|
83
|
+
},
|
|
84
|
+
delegate: {
|
|
85
|
+
// Push a turn into the runtime; resolve when handed off, not when answered.
|
|
86
|
+
deliverTurn: async (turn) => myRuntime.prompt(turn.content, turn.invocationId),
|
|
87
|
+
sessionControl,
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
wireLifecycle(session, process, { logPrefix: "[openclaw-channel]" })
|
|
92
|
+
await session.start()
|
|
93
|
+
|
|
94
|
+
// From inside the runtime, stream progress and close the turn:
|
|
95
|
+
await session.sendInterim(invocationId, "halfway there")
|
|
96
|
+
await session.reply(invocationId, "done, here is the result")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`start()` creates or resumes the scratchpad link for this identity (the
|
|
100
|
+
identity is derived from hostname and working directory, so a restart in the
|
|
101
|
+
same directory lands in the same scratchpad), opens the socket, and begins
|
|
102
|
+
claiming. `deliverTurn` receives each turn with the prompt, any hydrated
|
|
103
|
+
history, and downloaded attachments listed in the content.
|
|
104
|
+
|
|
105
|
+
## Asking the user a question
|
|
106
|
+
|
|
107
|
+
When the runtime hits a call it cannot make for itself — a tool approval, a
|
|
108
|
+
fork in the plan — `requestDecision` posts it to the scratchpad as a decision
|
|
109
|
+
card and resolves when the user answers it there:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const outcome = await session.requestDecision({
|
|
113
|
+
title: "Run `Bash`?",
|
|
114
|
+
body: "Delete the build directory",
|
|
115
|
+
options: [
|
|
116
|
+
{ id: "allow", label: "Allow", tone: "primary" },
|
|
117
|
+
{ id: "deny", label: "Deny", tone: "destructive" },
|
|
118
|
+
],
|
|
119
|
+
allowNote: true,
|
|
120
|
+
externalRef: requestId,
|
|
121
|
+
expiresInMs: 15 * 60 * 1000,
|
|
122
|
+
})
|
|
123
|
+
if (outcome.status === "resolved" && outcome.optionId === "allow") run()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The card lands on the active turn's stream (override with `streamId`) and is
|
|
127
|
+
attributed to the in-flight invocation when there is one (`invocationId`). The
|
|
128
|
+
answer arrives over the bot socket, with a poll as the missed-push backstop.
|
|
129
|
+
`status` is `"resolved"` (with `optionId` and the optional `note`),
|
|
130
|
+
`"cancelled"` or `"expired"`. Pass a `signal` to withdraw the card and reject
|
|
131
|
+
with an `AbortError`; `session.cancelDecision(id)` withdraws it directly. A
|
|
132
|
+
shutdown rejects every awaiting call with `DecisionAbandonedError`, and the
|
|
133
|
+
turn blocked on a decision stays alive while the card is open. Decisions on an
|
|
134
|
+
end-to-end encrypted scratchpad are not supported yet — the request is refused
|
|
135
|
+
with `E2E_STREAM_PLAINTEXT_UNSUPPORTED`.
|
|
136
|
+
|
|
137
|
+
## What the SDK decides for you
|
|
138
|
+
|
|
139
|
+
- One normal turn at a time. While a turn is in flight the session claims
|
|
140
|
+
session-control invocations only, so `/stop` and `/steer` reach the runtime
|
|
141
|
+
while the next message waits.
|
|
142
|
+
- `/stop` calls `actuator.interrupt()`, closes the in-flight turn (with a
|
|
143
|
+
"Stopped by /stop." note unless the turn had already posted an interim), and
|
|
144
|
+
does not pull the next queued message.
|
|
145
|
+
- `/steer` folds the text into the running turn through `actuator.steer` when
|
|
146
|
+
the actuator has it, otherwise interrupts and redelivers the steer text plus
|
|
147
|
+
any queued messages as one combined turn.
|
|
148
|
+
- Every other advertised command goes to `actuator.runCommand`, and its
|
|
149
|
+
returned `message` is posted as the acknowledgement.
|
|
150
|
+
- Claims are renewed every 40s on a 120s lease; a claim the server no longer
|
|
151
|
+
knows is dropped and presence is resynced.
|
|
152
|
+
- A turn that stays silent for `idleTimeoutMs` (default one hour, reset by
|
|
153
|
+
every `sendInterim`) is closed with a notice. Set it above the longest tool
|
|
154
|
+
call your runtime makes.
|
|
155
|
+
- `reply` and `sendInterim` return `{ ok, message, retryable }` instead of
|
|
156
|
+
throwing. `ok: false` with `retryable: true` means the request is still
|
|
157
|
+
open and the same call can be repeated.
|
|
158
|
+
- If the scratchpad is archived the session goes offline, fails its in-flight
|
|
159
|
+
turns, and waits for an unarchive. If none arrives within the grace window it
|
|
160
|
+
calls `delegate.onArchived`.
|
|
161
|
+
- `wireLifecycle` routes SIGINT, SIGTERM, SIGHUP, stdin close, and uncaught
|
|
162
|
+
errors through `shutdown()`, which marks presence offline and fails
|
|
163
|
+
in-flight claims so the scratchpad is never left "busy" with nobody behind it.
|
|
164
|
+
|
|
165
|
+
`delegate.onLinked(link)` runs on every link create or resume, if you need to
|
|
166
|
+
record which scratchpad this process owns.
|
|
167
|
+
|
|
168
|
+
## Delegations
|
|
169
|
+
|
|
170
|
+
`DelegationClient` speaks the delegation endpoints (`list`, `get`, `claim`,
|
|
171
|
+
`heartbeat`, `status`, `complete`, `fail`, `release`). `DelegationRunner` is an
|
|
172
|
+
optional loop over it: it polls or reacts to the `delegation:available` nudge,
|
|
173
|
+
claims, heartbeats the lease while your executor runs, and completes or fails
|
|
174
|
+
with the executor's result. A 404 from a claim-authenticated call means the
|
|
175
|
+
claim is lost; the runner aborts the executor and sends nothing further with
|
|
176
|
+
that token.
|
|
177
|
+
|
|
178
|
+
## Errors
|
|
179
|
+
|
|
180
|
+
HTTP failures throw `ThreaApiError` with `status` and the server's structured
|
|
181
|
+
`code` (for example `SCRATCHPAD_ARCHIVED`, `E2E_STREAM_PLAINTEXT_UNSUPPORTED`).
|
|
182
|
+
Socket failures never throw; the transport logs them through the `log` you
|
|
183
|
+
pass and falls back to HTTP.
|
|
184
|
+
|
|
185
|
+
## End-to-end encrypted scratchpads
|
|
186
|
+
|
|
187
|
+
Set `e2e: true` in the config (or `THREA_E2E=1`) to create the linked
|
|
188
|
+
scratchpad encrypted. The SDK mints the stream key, wraps it to the bot
|
|
189
|
+
owner's key and its own identity key, and from then on decrypts claims and
|
|
190
|
+
seals replies and trace steps locally. The owner must have set up encryption
|
|
191
|
+
in Threa first; until then `start()` logs the reason and retries on each poll.
|
|
192
|
+
|
|
193
|
+
`keyScope` (`THREA_E2E_KEY_SCOPE`) decides which installs share that identity
|
|
194
|
+
key: `host` (the default: every Threa runtime on this machine), `identity`
|
|
195
|
+
(this bot, wherever it runs), `instance` (this install alone), or `stream`
|
|
196
|
+
(one key per sealed scratchpad, minted when the bot is invited into it, so a
|
|
197
|
+
key that leaks opens that one scratchpad). `keyStore`
|
|
198
|
+
(`THREA_E2E_KEY_STORE`) picks where it is kept — `keychain` drives the OS
|
|
199
|
+
keychain through its command-line tool, which survives the runtime being
|
|
200
|
+
rebuilt, and `file` writes `0600` files under `keyDir`
|
|
201
|
+
(`THREA_E2E_KEY_DIR`, default `~/.threa/e2e-keys`). Leaving `keyStore` unset
|
|
202
|
+
takes the keychain when one works and asks you to choose when none does;
|
|
203
|
+
there is no silent downgrade to disk. A single-key file from before the
|
|
204
|
+
keyring (`bikPath`) is adopted under the configured scope, so scratchpads
|
|
205
|
+
already sealed to it keep opening.
|
|
206
|
+
|
|
207
|
+
When the owner takes the bot back off a sealed scratchpad, the server deletes
|
|
208
|
+
the wraps only that bot could open and the owner rolls the stream key forward,
|
|
209
|
+
so it reads nothing sent from then on. The runtime is told on the socket: it
|
|
210
|
+
drops the key it minted for that scratchpad, re-advertises what it still holds,
|
|
211
|
+
and the server stops registering the dropped one. Under any scope but `stream`
|
|
212
|
+
there is no such key to drop — the one key still opens the other scratchpads,
|
|
213
|
+
and the roll is what closed this one.
|
|
214
|
+
|
|
215
|
+
## Inside the Threa repo
|
|
216
|
+
|
|
217
|
+
This directory is consumed by `extensions/claude-code-remote` through a
|
|
218
|
+
`file:` dependency and runs from `src/`. `bun run build` writes the
|
|
219
|
+
publishable package to `dist/`; `bun run pack` produces the tarball.
|
package/attachments.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type AttachmentRef } from "@threahq/bot-runtime-client";
|
|
2
|
+
import type { AttachmentSummary, StreamMessageSummary, ThreaClient } from "./client.js";
|
|
3
|
+
/** A reply line `THREA_ATTACH: ./out.png` tells the channel to upload that file and attach it to the reply. */
|
|
4
|
+
export declare const ATTACH_DIRECTIVE_RE: RegExp;
|
|
5
|
+
/** Inbound attachments land under `<cwd>/.threa-attachments/<invocationId>/<attachmentId>/`, fresh per turn. */
|
|
6
|
+
export declare const ATTACHMENT_DIR = ".threa-attachments";
|
|
7
|
+
export interface SelectedAttachment {
|
|
8
|
+
attachment: AttachmentSummary;
|
|
9
|
+
messageId: string;
|
|
10
|
+
/** True when the attachment is on the message that triggered this turn. */
|
|
11
|
+
isSource: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface DownloadedAttachment extends SelectedAttachment {
|
|
14
|
+
localPath: string;
|
|
15
|
+
}
|
|
16
|
+
/** Strip `THREA_ATTACH:` directive lines out of a reply, returning the remaining text and the paths. */
|
|
17
|
+
export declare function extractAttachmentDirectives(markdown: string): {
|
|
18
|
+
markdown: string;
|
|
19
|
+
paths: string[];
|
|
20
|
+
};
|
|
21
|
+
export declare function guessMimeType(path: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Pick the attachments worth downloading: those on the source message (the
|
|
24
|
+
* request) plus any on the history messages Claude is being shown. De-duplicated
|
|
25
|
+
* by attachment id, source first so the manifest leads with the current request.
|
|
26
|
+
*/
|
|
27
|
+
export declare function selectInboundAttachments(messages: StreamMessageSummary[], sourceMessageId: string, contextMessageIds: readonly string[]): SelectedAttachment[];
|
|
28
|
+
/** The block appended to the channel event so Claude knows where the files landed. */
|
|
29
|
+
export declare function formatInboundAttachmentManifest(downloaded: DownloadedAttachment[]): string;
|
|
30
|
+
/** The attachment links / failure notes appended to an outbound reply. */
|
|
31
|
+
export declare function buildReplyAttachmentSection(uploaded: AttachmentSummary[], failed: string[]): string;
|
|
32
|
+
export declare function fetchAttachmentBytes(url: string, timeoutMs?: number, signal?: AbortSignal): Promise<Uint8Array>;
|
|
33
|
+
/**
|
|
34
|
+
* Discover the attachments on the messages Claude is being shown, download them
|
|
35
|
+
* into `<cwd>/.threa-attachments/<invocationId>/<attachmentId>/`, and return
|
|
36
|
+
* what landed. A per-attachment failure is logged and skipped rather than
|
|
37
|
+
* aborting the turn.
|
|
38
|
+
*/
|
|
39
|
+
export declare function downloadInboundAttachments(client: Pick<ThreaClient, "listStreamMessages" | "getAttachmentDownloadUrl">, params: {
|
|
40
|
+
streamId: string;
|
|
41
|
+
sourceMessageId: string;
|
|
42
|
+
contextMessageIds: readonly string[];
|
|
43
|
+
invocationId: string;
|
|
44
|
+
cwd: string;
|
|
45
|
+
scanLimit: number;
|
|
46
|
+
log: (message: string) => void;
|
|
47
|
+
/** Live canonical rebuilds fail instead of acknowledging a partial file set. */
|
|
48
|
+
strict?: boolean;
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
}): Promise<DownloadedAttachment[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Resolve `THREA_ATTACH:` directives in a reply: upload each referenced file and
|
|
53
|
+
* rewrite the reply to carry `attachment:<id>` links the backend associates with
|
|
54
|
+
* the posted message. Upload failures surface as a note rather than throwing.
|
|
55
|
+
*/
|
|
56
|
+
export declare function uploadReplyAttachments(client: Pick<ThreaClient, "uploadAttachment">, markdown: string, cwd: string): Promise<{
|
|
57
|
+
markdown: string;
|
|
58
|
+
uploaded: AttachmentSummary[];
|
|
59
|
+
failed: string[];
|
|
60
|
+
}>;
|
|
61
|
+
/**
|
|
62
|
+
* Server cap on `attachmentIds` per sealed message (`sealedAttachmentIdsSchema`
|
|
63
|
+
* caps at 16). Clamp before uploading: sending more ids would 400 the whole
|
|
64
|
+
* completion, and the retry loop would re-send the same over-limit body forever.
|
|
65
|
+
*/
|
|
66
|
+
export declare const MAX_SEALED_ATTACHMENTS_PER_MESSAGE = 16;
|
|
67
|
+
/**
|
|
68
|
+
* Resolve `THREA_ATTACH:` directives in SEALED output: encrypt each file under a
|
|
69
|
+
* fresh single-use key, upload only the ciphertext (`e2e=true`, placeholder
|
|
70
|
+
* name/mime), and return the refs to seal into the message payload plus the ids
|
|
71
|
+
* the wire body binds to the message row. No `attachment:<id>` links are added —
|
|
72
|
+
* an E2E viewer renders attachments from the sealed refs, not the markdown.
|
|
73
|
+
* Upload failures surface as a note (itself sealed) rather than throwing.
|
|
74
|
+
*/
|
|
75
|
+
export declare function uploadSealedReplyAttachments(client: Pick<ThreaClient, "uploadAttachment">, markdown: string, cwd: string): Promise<{
|
|
76
|
+
markdown: string;
|
|
77
|
+
refs: AttachmentRef[];
|
|
78
|
+
attachmentIds: string[];
|
|
79
|
+
}>;
|
|
80
|
+
/** One inbound sealed attachment to fetch: the ref plus whether it rode the trigger message. */
|
|
81
|
+
export interface SealedInboundRef {
|
|
82
|
+
ref: AttachmentRef;
|
|
83
|
+
isSource: boolean;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Dedupe the refs opened from a sealed claim (trigger payload + history
|
|
87
|
+
* payloads) by attachment id, trigger first — the sealed sibling of
|
|
88
|
+
* `selectInboundAttachments`, working from decrypted refs instead of the
|
|
89
|
+
* plaintext message list (which only holds placeholders on an E2E stream).
|
|
90
|
+
*/
|
|
91
|
+
export declare function selectSealedInboundRefs(promptRefs: readonly AttachmentRef[], historyRefs: readonly AttachmentRef[]): SealedInboundRef[];
|
|
92
|
+
/**
|
|
93
|
+
* Download + decrypt a sealed turn's inbound attachments into
|
|
94
|
+
* `<cwd>/.threa-attachments/<invocationId>/<attachmentId>/`. The S3 object is opaque
|
|
95
|
+
* ciphertext; the ref's key/iv (opened from the sealed message payload) decrypt
|
|
96
|
+
* it locally, and the file lands under its REAL name — decrypted bytes never
|
|
97
|
+
* transit the server. A per-attachment failure is logged and skipped.
|
|
98
|
+
*/
|
|
99
|
+
export declare function downloadSealedInboundAttachments(client: Pick<ThreaClient, "getAttachmentDownloadUrl">, params: {
|
|
100
|
+
refs: SealedInboundRef[];
|
|
101
|
+
invocationId: string;
|
|
102
|
+
cwd: string;
|
|
103
|
+
log: (message: string) => void;
|
|
104
|
+
/** Live canonical rebuilds fail instead of acknowledging a partial file set. */
|
|
105
|
+
strict?: boolean;
|
|
106
|
+
signal?: AbortSignal;
|
|
107
|
+
}): Promise<DownloadedAttachment[]>;
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type CreateDecisionRequestBody, type DecisionRequest, type AttachmentRef, type ProvisionedWrap, type SealedReplyBody, type SealingState } from "@threahq/bot-runtime-client";
|
|
2
|
+
/**
|
|
3
|
+
* A sealed message body on the wire: the sealed ciphertext plus the E2E
|
|
4
|
+
* attachment row ids the server binds to the message (the per-file keys ride
|
|
5
|
+
* only inside the sealed payload's `attachmentRefs`).
|
|
6
|
+
*/
|
|
7
|
+
export type SealedWireReply = SealedReplyBody & {
|
|
8
|
+
attachmentIds?: string[];
|
|
9
|
+
};
|
|
10
|
+
export interface RuntimeSessionLink {
|
|
11
|
+
linkId: string;
|
|
12
|
+
rootStreamId: string;
|
|
13
|
+
activeStreamId: string;
|
|
14
|
+
runtimeSessionId: string;
|
|
15
|
+
streamUrlPath: string;
|
|
16
|
+
/** The linked scratchpad's encryption state (create echoes the request; resume reports the actual state). */
|
|
17
|
+
e2eEnabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface ExternalHistoryMessage {
|
|
20
|
+
messageId: string;
|
|
21
|
+
role: "user" | "assistant";
|
|
22
|
+
authorId: string;
|
|
23
|
+
authorType: string;
|
|
24
|
+
authorDisplayName?: string;
|
|
25
|
+
contentMarkdown: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
}
|
|
28
|
+
export interface AttachmentSummary {
|
|
29
|
+
id: string;
|
|
30
|
+
filename: string;
|
|
31
|
+
mimeType: string;
|
|
32
|
+
sizeBytes: number;
|
|
33
|
+
}
|
|
34
|
+
/** The slice of `GET /streams/:id/messages` we consume — id plus any attachments. */
|
|
35
|
+
export interface StreamMessageSummary {
|
|
36
|
+
id: string;
|
|
37
|
+
attachments?: AttachmentSummary[];
|
|
38
|
+
}
|
|
39
|
+
export interface ClaimedInvocation {
|
|
40
|
+
id: string;
|
|
41
|
+
workspaceId: string;
|
|
42
|
+
rootStreamId: string;
|
|
43
|
+
activeStreamId: string;
|
|
44
|
+
sourceMessageId: string;
|
|
45
|
+
sourceRevision: number;
|
|
46
|
+
responseStreamId: string;
|
|
47
|
+
actor: {
|
|
48
|
+
type: "bot";
|
|
49
|
+
id: string;
|
|
50
|
+
slug: string;
|
|
51
|
+
};
|
|
52
|
+
trigger: string;
|
|
53
|
+
requiredCapability: string;
|
|
54
|
+
promptMarkdown: string;
|
|
55
|
+
authorUserId: string;
|
|
56
|
+
mentionedActorSlugs: string[];
|
|
57
|
+
claimToken: string;
|
|
58
|
+
claimExpiresAt: string;
|
|
59
|
+
runtimeSessionId: string | null;
|
|
60
|
+
metadata: Record<string, unknown>;
|
|
61
|
+
context?: {
|
|
62
|
+
kind: "inline";
|
|
63
|
+
messages: ExternalHistoryMessage[];
|
|
64
|
+
};
|
|
65
|
+
/** Present on a sealed (E2E) claim as delivered by the server; consumed and cleared by hydration. */
|
|
66
|
+
sealedContext?: unknown;
|
|
67
|
+
/** Present on a session-control claim on an E2E stream: SSK wraps to seal the command ack. */
|
|
68
|
+
sealedAck?: unknown;
|
|
69
|
+
/** Derived from `sealedContext` at claim time; carries the stream key + binding for sealing replies/steps. */
|
|
70
|
+
sealing?: SealingState;
|
|
71
|
+
/** Attachment refs opened from the sealed trigger/history payloads at claim time — download + decrypt is the turn's job. */
|
|
72
|
+
sealedAttachments?: {
|
|
73
|
+
prompt: AttachmentRef[];
|
|
74
|
+
history: AttachmentRef[];
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export declare class ThreaApiError extends Error {
|
|
78
|
+
readonly status: number;
|
|
79
|
+
/** The server's structured error `code` (e.g. `E2E_STREAM_PLAINTEXT_UNSUPPORTED`), when the body was JSON. */
|
|
80
|
+
readonly code?: string | undefined;
|
|
81
|
+
readonly retryAfterMs?: number | undefined;
|
|
82
|
+
constructor(message: string, status: number,
|
|
83
|
+
/** The server's structured error `code` (e.g. `E2E_STREAM_PLAINTEXT_UNSUPPORTED`), when the body was JSON. */
|
|
84
|
+
code?: string | undefined, retryAfterMs?: number | undefined);
|
|
85
|
+
}
|
|
86
|
+
export interface ThreaClientOptions {
|
|
87
|
+
baseUrl: string;
|
|
88
|
+
workspaceId: string;
|
|
89
|
+
apiKey: string;
|
|
90
|
+
fetchTimeoutMs?: number;
|
|
91
|
+
}
|
|
92
|
+
export declare class ThreaClient {
|
|
93
|
+
private readonly opts;
|
|
94
|
+
constructor(opts: ThreaClientOptions);
|
|
95
|
+
private get base();
|
|
96
|
+
private request;
|
|
97
|
+
private requestWithin;
|
|
98
|
+
private workspacePath;
|
|
99
|
+
/** Returns the authenticated principal; only `.kind` (`"bot"` vs `"user"`) is consumed today. */
|
|
100
|
+
getMe(): Promise<{
|
|
101
|
+
kind: string;
|
|
102
|
+
}>;
|
|
103
|
+
createSession(body: Record<string, unknown>): Promise<RuntimeSessionLink>;
|
|
104
|
+
claim(body: Record<string, unknown>): Promise<ClaimedInvocation | null>;
|
|
105
|
+
complete(invocationId: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<void>;
|
|
106
|
+
fail(invocationId: string, body: Record<string, unknown>): Promise<void>;
|
|
107
|
+
sendMessage(streamId: string, body: Record<string, unknown>): Promise<{
|
|
108
|
+
id: string;
|
|
109
|
+
}>;
|
|
110
|
+
sendInvocationMessage(invocationId: string, body: Record<string, unknown>): Promise<void>;
|
|
111
|
+
sendSealedMessage(invocationId: string, callbackToken: string, body: SealedWireReply): Promise<void>;
|
|
112
|
+
/** The bot owner's active encryption key (public half). 404 = the owner has not set up encryption. */
|
|
113
|
+
getOwnerE2eKey(): Promise<{
|
|
114
|
+
keyId: string;
|
|
115
|
+
publicKey: string;
|
|
116
|
+
}>;
|
|
117
|
+
/** Phase two of harness-created E2E scratchpads: store the generation-0 stream-key wraps. */
|
|
118
|
+
provisionStreamKeyWraps(streamId: string, body: {
|
|
119
|
+
keyGeneration: number;
|
|
120
|
+
wraps: ProvisionedWrap[];
|
|
121
|
+
}): Promise<void>;
|
|
122
|
+
/** Complete a sealed turn with its final sealed reply — or silently (`noResponse`). Callback-token auth. */
|
|
123
|
+
completeSealed(invocationId: string, callbackToken: string, body: ({
|
|
124
|
+
reply: SealedWireReply;
|
|
125
|
+
} | {
|
|
126
|
+
noResponse: true;
|
|
127
|
+
}) & {
|
|
128
|
+
sourceRevision: number;
|
|
129
|
+
}, signal?: AbortSignal): Promise<void>;
|
|
130
|
+
/** Open a decision card on a stream and return the created request. Bot key only. */
|
|
131
|
+
requestDecision(streamId: string, body: CreateDecisionRequestBody): Promise<DecisionRequest>;
|
|
132
|
+
/** Read one decision this bot opened. 404 once it is out of the caller's scope. */
|
|
133
|
+
getDecision(decisionId: string): Promise<DecisionRequest>;
|
|
134
|
+
/** Withdraw a decision this bot opened. */
|
|
135
|
+
cancelDecision(decisionId: string): Promise<DecisionRequest>;
|
|
136
|
+
/** Recent messages for a stream, newest-window first. Used to discover inbound attachments (the claim context omits them). Requires `messages:read` + `streams:read`. */
|
|
137
|
+
listStreamMessages(streamId: string, query?: {
|
|
138
|
+
limit?: number;
|
|
139
|
+
}): Promise<StreamMessageSummary[]>;
|
|
140
|
+
/** `archivedAt` for a stream, or null while it is live. Requires `streams:read`. */
|
|
141
|
+
getStreamArchivedAt(streamId: string): Promise<string | null>;
|
|
142
|
+
/** Short-lived signed download URL for an attachment. Requires `attachments:read`. */
|
|
143
|
+
getAttachmentDownloadUrl(attachmentId: string): Promise<string>;
|
|
144
|
+
/** Upload a file (multipart `file` field) and return its summary. Requires `attachments:write`. */
|
|
145
|
+
uploadAttachment(form: FormData): Promise<AttachmentSummary>;
|
|
146
|
+
}
|
package/config-file.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type RawConfig } from "./identity.js";
|
|
2
|
+
/**
|
|
3
|
+
* A connector's optional JSON config file. Absent is undefined; a damaged file
|
|
4
|
+
* is logged and ignored so env vars can still carry the config.
|
|
5
|
+
*/
|
|
6
|
+
export declare function readConfigFile(path: string, log: (message: string) => void): RawConfig | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Owner-only from the first byte, then swapped in whole: an existing file's
|
|
9
|
+
* looser mode never applies to the new content, and a reader or a crash never
|
|
10
|
+
* sees a half-written file.
|
|
11
|
+
*/
|
|
12
|
+
export declare function writeFileAtomic(path: string, content: string, mode?: number): void;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Wire shape of a delegation on the public API (list + lifecycle responses). */
|
|
2
|
+
export interface DelegationSummary {
|
|
3
|
+
id: string;
|
|
4
|
+
streamId: string;
|
|
5
|
+
title: string;
|
|
6
|
+
status: string;
|
|
7
|
+
claimedByLabel?: string;
|
|
8
|
+
statusNote?: string;
|
|
9
|
+
resultMessageId?: string;
|
|
10
|
+
sourceConversationId?: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
statusChangedAt: string;
|
|
13
|
+
}
|
|
14
|
+
/** Inspect response: the full working set without claim credentials. */
|
|
15
|
+
export interface InspectedDelegation extends DelegationSummary {
|
|
16
|
+
brief: string;
|
|
17
|
+
contextRefs: string[];
|
|
18
|
+
claimExpiresAt?: string;
|
|
19
|
+
}
|
|
20
|
+
/** The claim response: the executor's full working set plus the one-time token. */
|
|
21
|
+
export interface ClaimedDelegation extends InspectedDelegation {
|
|
22
|
+
/** Cleartext, returned exactly once — send it back as X-Threa-Callback-Token. */
|
|
23
|
+
claimToken: string;
|
|
24
|
+
claimExpiresAt: string;
|
|
25
|
+
}
|
|
26
|
+
export interface DelegationClientOptions {
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
workspaceId: string;
|
|
29
|
+
apiKey: string;
|
|
30
|
+
fetchTimeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* HTTP client for the delegation lifecycle (roadmap 5.3/5.4) — a deliberate
|
|
34
|
+
* sibling of `ThreaClient`, not an extension of it: the bot-runtime surface is
|
|
35
|
+
* bot-key-only with body-carried claim tokens, while delegations accept either
|
|
36
|
+
* key kind and authenticate transitions with the `X-Threa-Callback-Token`
|
|
37
|
+
* header. Keeping the clients separate keeps the two credential/transport
|
|
38
|
+
* models from cross-contaminating (the recorded 5.3 ruling).
|
|
39
|
+
*/
|
|
40
|
+
export declare class DelegationClient {
|
|
41
|
+
private readonly opts;
|
|
42
|
+
constructor(opts: DelegationClientOptions);
|
|
43
|
+
private get base();
|
|
44
|
+
private path;
|
|
45
|
+
private request;
|
|
46
|
+
private requestWithin;
|
|
47
|
+
/** Inspect a delegation without claiming it. The response never contains claim credentials. */
|
|
48
|
+
get(id: string): Promise<InspectedDelegation>;
|
|
49
|
+
/** Open delegations the key can access, oldest first. `since` narrows to a delta. */
|
|
50
|
+
listOpen(opts?: {
|
|
51
|
+
since?: string;
|
|
52
|
+
}): Promise<DelegationSummary[]>;
|
|
53
|
+
/**
|
|
54
|
+
* CAS-claim one delegation. Persist `idempotencyKey` BEFORE calling: a retry
|
|
55
|
+
* bearing the live claim's key re-keys it (fresh token + lease) instead of
|
|
56
|
+
* 409ing — the crash-between-response-and-persist recovery path.
|
|
57
|
+
* Throws `ThreaApiError` 409 (`DELEGATION_NOT_OPEN`) on a lost race.
|
|
58
|
+
*/
|
|
59
|
+
claim(id: string, body: {
|
|
60
|
+
claimedByLabel: string;
|
|
61
|
+
idempotencyKey?: string;
|
|
62
|
+
}): Promise<ClaimedDelegation>;
|
|
63
|
+
/** Release a live claim back to the open queue. */
|
|
64
|
+
release(id: string, claimToken: string): Promise<DelegationSummary>;
|
|
65
|
+
/** Renew the 15-minute lease. Liveness only — nothing changes on the card. */
|
|
66
|
+
heartbeat(id: string, claimToken: string): Promise<{
|
|
67
|
+
claimExpiresAt: string;
|
|
68
|
+
}>;
|
|
69
|
+
/** Progress note on the card (`claimed|running → running`); also renews the lease. */
|
|
70
|
+
reportStatus(id: string, claimToken: string, statusNote: string): Promise<DelegationSummary>;
|
|
71
|
+
/**
|
|
72
|
+
* Terminal success. `resultMarkdown` posts into the delegation card's thread
|
|
73
|
+
* as the key's identity in the same transaction as the flip; retries with the
|
|
74
|
+
* same token are idempotent (the committed outcome comes back, nothing double-posts).
|
|
75
|
+
*/
|
|
76
|
+
complete(id: string, claimToken: string, body: {
|
|
77
|
+
resultMarkdown?: string;
|
|
78
|
+
metadata?: Record<string, string>;
|
|
79
|
+
}): Promise<DelegationSummary & {
|
|
80
|
+
resultMessageId?: string;
|
|
81
|
+
resultThreadId?: string;
|
|
82
|
+
}>;
|
|
83
|
+
/** Terminal failure: the reason lands on the card. Idempotent like complete. */
|
|
84
|
+
fail(id: string, claimToken: string, errorMessage: string): Promise<DelegationSummary>;
|
|
85
|
+
/**
|
|
86
|
+
* Ask a stream member to grant this bot access to the delegation's stream
|
|
87
|
+
* (F3). Called when a claim 404s for lack of a channel grant — files a card a
|
|
88
|
+
* member approves or denies. No claim token: the bot has no claim yet. Returns
|
|
89
|
+
* `{ status: "already_granted" }` (no `requestId`) when the bot already had
|
|
90
|
+
* access, else `{ requestId, status: "open" }`; idempotent per (bot, stream).
|
|
91
|
+
*/
|
|
92
|
+
requestAccess(delegationId: string, opts?: {
|
|
93
|
+
requestedByLabel?: string;
|
|
94
|
+
}): Promise<{
|
|
95
|
+
requestId?: string;
|
|
96
|
+
status: string;
|
|
97
|
+
}>;
|
|
98
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ClaimedDelegation, DelegationClient } from "./delegation-client.js";
|
|
2
|
+
export declare const DELEGATION_STOP_REASON = "runner_shutdown";
|
|
3
|
+
export interface DelegationExecutorContext {
|
|
4
|
+
signal: AbortSignal;
|
|
5
|
+
reportStatus(note: string): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export type DelegationExecutor = (task: ClaimedDelegation, ctx: DelegationExecutorContext) => Promise<{
|
|
8
|
+
resultMarkdown?: string;
|
|
9
|
+
metadata?: Record<string, string>;
|
|
10
|
+
} | void>;
|
|
11
|
+
export interface DelegationRunnerOptions {
|
|
12
|
+
client: DelegationClient;
|
|
13
|
+
executor: DelegationExecutor;
|
|
14
|
+
claimedByLabel: string;
|
|
15
|
+
persistIdempotencyKey?: (delegationId: string, key: string) => void | Promise<void>;
|
|
16
|
+
pollMs?: number;
|
|
17
|
+
heartbeatMs?: number;
|
|
18
|
+
/** Maximum controlled-stop wait. Primarily useful to bound host reconnects. */
|
|
19
|
+
shutdownWaitMs?: number;
|
|
20
|
+
log?: (message: string) => void;
|
|
21
|
+
}
|
|
22
|
+
export declare class DelegationRunner {
|
|
23
|
+
private readonly client;
|
|
24
|
+
private readonly executor;
|
|
25
|
+
private readonly claimedByLabel;
|
|
26
|
+
private readonly persistIdempotencyKey?;
|
|
27
|
+
private readonly pollMs;
|
|
28
|
+
private readonly heartbeatMs;
|
|
29
|
+
private readonly shutdownWaitMs;
|
|
30
|
+
private readonly log;
|
|
31
|
+
private stopped;
|
|
32
|
+
private generation;
|
|
33
|
+
private current;
|
|
34
|
+
private stopOperation;
|
|
35
|
+
private pollTimer;
|
|
36
|
+
private active;
|
|
37
|
+
private readonly pendingNudged;
|
|
38
|
+
private readonly accessRequested;
|
|
39
|
+
constructor(opts: DelegationRunnerOptions);
|
|
40
|
+
start(): void;
|
|
41
|
+
stop(_reason?: string, options?: {
|
|
42
|
+
strict?: boolean;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
notifyAvailable(nudge?: {
|
|
45
|
+
delegationId?: string;
|
|
46
|
+
}): void;
|
|
47
|
+
private isCurrent;
|
|
48
|
+
private drain;
|
|
49
|
+
private runDrain;
|
|
50
|
+
private tryClaim;
|
|
51
|
+
private tryClaimNudged;
|
|
52
|
+
private requestAccessOnce;
|
|
53
|
+
private releaseStoppedClaim;
|
|
54
|
+
private createActiveClaim;
|
|
55
|
+
private releaseActive;
|
|
56
|
+
private execute;
|
|
57
|
+
}
|