@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 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.
@@ -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
+ }
@@ -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
+ }