@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// A scratchpad-linked connector: the runtime owns a scratchpad, every message
|
|
2
|
+
// in it is a turn, and /steer and /stop work from the composer.
|
|
3
|
+
//
|
|
4
|
+
// THREA_WORKSPACE_ID=ws_… THREA_API_KEY=threa_bk_… bun examples/echo-connector.ts
|
|
5
|
+
//
|
|
6
|
+
// The "runtime" here is a timer that echoes the turn after ECHO_DELAY_MS, so the
|
|
7
|
+
// session-control paths have something to interrupt. Replace `runtime` with a
|
|
8
|
+
// bridge to a real agent; everything else stays.
|
|
9
|
+
|
|
10
|
+
import { hostname } from "node:os"
|
|
11
|
+
import { RemoteSession, ThreaClient, loadConfig, wireLifecycle } from "@threahq/remote-session"
|
|
12
|
+
|
|
13
|
+
const runtimeKind = process.env.THREA_RUNTIME_KIND ?? "custom"
|
|
14
|
+
const echoDelayMs = Number(process.env.ECHO_DELAY_MS ?? 0)
|
|
15
|
+
|
|
16
|
+
const loaded = loadConfig(
|
|
17
|
+
{ env: process.env, cwd: process.cwd(), hostname: hostname() },
|
|
18
|
+
{ idPrefix: "echo", sessionIdPrefix: "echos", displayNamePrefix: "Echo" }
|
|
19
|
+
)
|
|
20
|
+
if ("error" in loaded) {
|
|
21
|
+
console.error(loaded.error)
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let pending: { invocationId: string; content: string; timer: ReturnType<typeof setTimeout> } | undefined
|
|
26
|
+
|
|
27
|
+
const runtime = {
|
|
28
|
+
prompt(invocationId: string, content: string): void {
|
|
29
|
+
void session.sendInterim(invocationId, "Working on it.")
|
|
30
|
+
const timer = setTimeout(async () => {
|
|
31
|
+
const turn = pending
|
|
32
|
+
pending = undefined
|
|
33
|
+
if (!turn) return
|
|
34
|
+
await session.reply(turn.invocationId, `Echo: ${turn.content}`)
|
|
35
|
+
}, echoDelayMs)
|
|
36
|
+
pending = { invocationId, content, timer }
|
|
37
|
+
},
|
|
38
|
+
interrupt(): boolean {
|
|
39
|
+
if (pending) clearTimeout(pending.timer)
|
|
40
|
+
pending = undefined
|
|
41
|
+
return true
|
|
42
|
+
},
|
|
43
|
+
steer(text: string): boolean {
|
|
44
|
+
if (!pending) return false
|
|
45
|
+
pending.content = `${pending.content}\n${text}`
|
|
46
|
+
return true
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const session = new RemoteSession({
|
|
51
|
+
config: loaded.config,
|
|
52
|
+
client: new ThreaClient(loaded.config),
|
|
53
|
+
runtime: {
|
|
54
|
+
kind: runtimeKind,
|
|
55
|
+
busyStatusText: "Echoing…",
|
|
56
|
+
forwardedNote: "Forwarded to the echo runtime.",
|
|
57
|
+
shutdownErrorMessage: "Echo connector shut down",
|
|
58
|
+
},
|
|
59
|
+
delegate: {
|
|
60
|
+
deliverTurn: async (turn) => runtime.prompt(turn.invocationId, turn.content),
|
|
61
|
+
sessionControl: {
|
|
62
|
+
commands: ["stop", "steer"],
|
|
63
|
+
interrupt: () => runtime.interrupt(),
|
|
64
|
+
steer: (text) => runtime.steer(text),
|
|
65
|
+
runCommand: async (name) => ({ ok: false, message: `Unsupported command: /${name}` }),
|
|
66
|
+
},
|
|
67
|
+
onLinked: (link) => console.error(`linked to ${loaded.config.baseUrl}${link.streamUrlPath}`),
|
|
68
|
+
},
|
|
69
|
+
log: (line) => console.error(`[echo] ${line}`),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
wireLifecycle(session, process, { logPrefix: "[echo]" })
|
|
73
|
+
await session.start()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// A mention-driven bot: any runtime kind (here `custom`) can claim the work
|
|
2
|
+
// Threa creates when someone @mentions the bot, do it, and reply.
|
|
3
|
+
//
|
|
4
|
+
// THREA_WORKSPACE_ID=ws_… THREA_API_KEY=threa_bk_… bun examples/mention-bot.ts
|
|
5
|
+
//
|
|
6
|
+
// The socket delivers "work is claimable" nudges and carries presence and claim
|
|
7
|
+
// renewals; the claim, the reply, and the poll backstop are plain HTTP.
|
|
8
|
+
|
|
9
|
+
import { BotRuntimeTransport } from "@threahq/bot-runtime-client"
|
|
10
|
+
import { ThreaClient } from "@threahq/remote-session"
|
|
11
|
+
|
|
12
|
+
const baseUrl = process.env.THREA_BASE_URL ?? "https://app.threa.io"
|
|
13
|
+
const workspaceId = process.env.THREA_WORKSPACE_ID!
|
|
14
|
+
const apiKey = process.env.THREA_API_KEY!
|
|
15
|
+
const instanceId = process.env.THREA_INSTANCE_ID ?? "mention-bot-1"
|
|
16
|
+
const runtimeKind = "custom"
|
|
17
|
+
|
|
18
|
+
const client = new ThreaClient({ baseUrl, workspaceId, apiKey })
|
|
19
|
+
const transport = new BotRuntimeTransport({
|
|
20
|
+
baseUrl,
|
|
21
|
+
workspaceId,
|
|
22
|
+
apiKey,
|
|
23
|
+
hello: { instanceId, runtimeKind, supportedCapabilities: ["mentionable"] },
|
|
24
|
+
callbacks: { onInvocationAvailable: () => void drain(), onBootstrap: () => void drain() },
|
|
25
|
+
log: (line) => console.error(`[transport] ${line}`),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const presence = (status: "available" | "busy" | "offline") =>
|
|
29
|
+
transport.updatePresence({
|
|
30
|
+
runtimeKind,
|
|
31
|
+
instanceId,
|
|
32
|
+
status,
|
|
33
|
+
acceptingInvocations: status === "available",
|
|
34
|
+
capabilities: {},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
let draining = false
|
|
38
|
+
async function drain(): Promise<void> {
|
|
39
|
+
if (draining) return
|
|
40
|
+
draining = true
|
|
41
|
+
try {
|
|
42
|
+
for (;;) {
|
|
43
|
+
const invocation = await client.claim({
|
|
44
|
+
runtimeKind,
|
|
45
|
+
instanceId,
|
|
46
|
+
supportedCapabilities: ["mentionable"],
|
|
47
|
+
claimTtlSeconds: 120,
|
|
48
|
+
})
|
|
49
|
+
if (!invocation) return
|
|
50
|
+
await presence("busy")
|
|
51
|
+
// Renew while working so a slow answer never loses the claim. If the
|
|
52
|
+
// server says the claim is gone, or has not confirmed the lease for long
|
|
53
|
+
// enough that it may have expired, the reply has nowhere to land.
|
|
54
|
+
let claimLost = false
|
|
55
|
+
let leaseConfirmedAt = Date.now()
|
|
56
|
+
const renew = setInterval(async () => {
|
|
57
|
+
const { notFound, renewed } = await transport.renewClaim(invocation.id, invocation.claimToken, 120)
|
|
58
|
+
if (renewed) leaseConfirmedAt = Date.now()
|
|
59
|
+
if (notFound || (!renewed && Date.now() - leaseConfirmedAt > 80_000)) {
|
|
60
|
+
claimLost = true
|
|
61
|
+
clearInterval(renew)
|
|
62
|
+
console.error(`[bot] claim ${invocation.id} lost or unconfirmed; dropping the reply`)
|
|
63
|
+
}
|
|
64
|
+
}, 40_000)
|
|
65
|
+
try {
|
|
66
|
+
await transport.recordSteps(invocation.id, invocation.claimToken, [
|
|
67
|
+
{ stepType: "thinking", content: "Composing a reply" },
|
|
68
|
+
])
|
|
69
|
+
const reply = await answer(invocation.promptMarkdown)
|
|
70
|
+
if (claimLost) continue
|
|
71
|
+
await client.complete(invocation.id, {
|
|
72
|
+
instanceId,
|
|
73
|
+
claimToken: invocation.claimToken,
|
|
74
|
+
finalMessageMarkdown: reply,
|
|
75
|
+
})
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await client
|
|
78
|
+
.fail(invocation.id, {
|
|
79
|
+
instanceId,
|
|
80
|
+
claimToken: invocation.claimToken,
|
|
81
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
82
|
+
})
|
|
83
|
+
.catch((failure) => console.error(`[bot] could not fail ${invocation.id}: ${failure}`))
|
|
84
|
+
} finally {
|
|
85
|
+
clearInterval(renew)
|
|
86
|
+
await presence("available")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// A transient claim failure is logged; the socket nudge or the backstop
|
|
91
|
+
// poll runs the loop again. Nothing here should end the process.
|
|
92
|
+
console.error(`[bot] claim loop: ${error instanceof Error ? error.message : String(error)}`)
|
|
93
|
+
} finally {
|
|
94
|
+
draining = false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Replace with your agent.
|
|
99
|
+
async function answer(prompt: string): Promise<string> {
|
|
100
|
+
return `You said: ${prompt}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const backstop = setInterval(() => void drain(), 30_000)
|
|
104
|
+
const heartbeat = setInterval(() => void presence(draining ? "busy" : "available"), 20_000)
|
|
105
|
+
let stopping = false
|
|
106
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
107
|
+
process.on(signal, async () => {
|
|
108
|
+
if (stopping) return
|
|
109
|
+
stopping = true
|
|
110
|
+
clearInterval(backstop)
|
|
111
|
+
clearInterval(heartbeat)
|
|
112
|
+
transport.disconnect()
|
|
113
|
+
await presence("offline")
|
|
114
|
+
process.exit(0)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await presence("available")
|
|
119
|
+
await transport.connect()
|
|
120
|
+
await drain()
|
package/identity.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type E2eKeyScope, type E2eKeyStoreKind } from "@threahq/bot-runtime-client";
|
|
2
|
+
export declare const TRACE_MODES: readonly ["headline", "commands"];
|
|
3
|
+
export type TraceMode = (typeof TRACE_MODES)[number];
|
|
4
|
+
export interface RemoteSessionConfig {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
/** Scratchpad display name: the configured prefix with the project directory appended. */
|
|
9
|
+
displayName: string;
|
|
10
|
+
/** Sent as `labelName` on session create; the backend applies it only to a newly created scratchpad. Unset = no label. */
|
|
11
|
+
defaultLabel?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Recorded on the session link as the runtime's working directory, for a
|
|
14
|
+
* supervisor (harnessd) that reaps worktrees. Unset = not sent; a public
|
|
15
|
+
* connector has no reason to upload a local path.
|
|
16
|
+
*/
|
|
17
|
+
localCwd?: string;
|
|
18
|
+
/** Cold-start behavior when this identity still points at an archived scratchpad. Default: replace. */
|
|
19
|
+
coldStartIfArchived?: "wait" | "replace";
|
|
20
|
+
/** Cold-start behavior when this identity has no session link. Default: create. */
|
|
21
|
+
coldStartIfMissing?: "create" | "error";
|
|
22
|
+
/** When set by a supervisor, refuse a session link to any other scratchpad root. */
|
|
23
|
+
expectedRootStreamId?: string;
|
|
24
|
+
/** `^[A-Za-z0-9_-]+$`, ≤64 — must satisfy the `/bot` hello schema. */
|
|
25
|
+
instanceId: string;
|
|
26
|
+
runtimeSessionId: string;
|
|
27
|
+
/** Relay the runtime's tool-approval prompts into the scratchpad for remote approval. */
|
|
28
|
+
permissionRelay: boolean;
|
|
29
|
+
/** Backstop claim-poll cadence; the `/bot` socket pushes work faster than this. */
|
|
30
|
+
pollMs: number;
|
|
31
|
+
/**
|
|
32
|
+
* Safety net for a wedged turn: an in-flight invocation is force-closed after
|
|
33
|
+
* this much *inactivity*. Every interim send (and tool-approval activity)
|
|
34
|
+
* resets it, so an actively-working turn never trips it — only one that went
|
|
35
|
+
* silent without a reply. Must exceed the longest single tool call the agent
|
|
36
|
+
* makes, since it can't heartbeat while blocked on a tool.
|
|
37
|
+
*/
|
|
38
|
+
idleTimeoutMs: number;
|
|
39
|
+
/**
|
|
40
|
+
* The single-key BIK file this install used before keyrings. Still read: when
|
|
41
|
+
* the configured key scope holds nothing yet, the old key is adopted under it
|
|
42
|
+
* so the owner's existing wraps keep addressing a key this runtime holds.
|
|
43
|
+
* Unset = a per-runtime-kind default under `~/.threa/`.
|
|
44
|
+
*/
|
|
45
|
+
bikPath?: string;
|
|
46
|
+
/**
|
|
47
|
+
* How widely this install's E2E identity key is shared. `host` (default) is
|
|
48
|
+
* one key for every runtime on this machine, so a person running several
|
|
49
|
+
* agents invites one recipient rather than one per agent. `identity` is one
|
|
50
|
+
* key per bot (per API key) across machines, `instance` one per install.
|
|
51
|
+
* Changing it points the runtime at a different key, so the owner must
|
|
52
|
+
* re-invite it to streams wrapped under the old one.
|
|
53
|
+
*/
|
|
54
|
+
keyScope: E2eKeyScope;
|
|
55
|
+
/**
|
|
56
|
+
* Where E2E keys are kept. Unset lets a working OS keychain win and otherwise
|
|
57
|
+
* asks rather than choosing disk on the operator's behalf; `file` keeps them
|
|
58
|
+
* at mode 0600 under `keyDir`; `keychain` requires one and fails loudly
|
|
59
|
+
* without it.
|
|
60
|
+
*/
|
|
61
|
+
keyStore?: E2eKeyStoreKind;
|
|
62
|
+
/** Directory for the file key store. Default `~/.threa/e2e-keys`. */
|
|
63
|
+
keyDir?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Emit FULL trace detail (real commands, file contents, outputs) on sealed
|
|
66
|
+
* (E2EE) turns — safe because sealed step content is ciphertext the server
|
|
67
|
+
* can't read. Default on; set false to use `traceMode` on sealed turns too.
|
|
68
|
+
* Has no effect on plaintext turns, which never emit full detail.
|
|
69
|
+
*/
|
|
70
|
+
sealedFullTrace: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Base trace detail. `headline` keeps shell commands hidden; `commands`
|
|
73
|
+
* includes only the Bash command while file bodies, patches, and every tool
|
|
74
|
+
* result stay hidden. Used for plaintext turns and sealed turns when
|
|
75
|
+
* `sealedFullTrace` is false. Defaults to `headline`.
|
|
76
|
+
*/
|
|
77
|
+
traceMode: TraceMode;
|
|
78
|
+
/**
|
|
79
|
+
* Create this connector's linked scratchpad end-to-end encrypted: the harness
|
|
80
|
+
* mints the stream key and wraps it to the bot owner's UIK + its own BIK, so
|
|
81
|
+
* the server only ever stores ciphertext. Requires the owner to have set up
|
|
82
|
+
* encryption in Threa (their UIK is fetched at session create). Off by
|
|
83
|
+
* default — an encrypted scratchpad opts out of GAM memory extraction.
|
|
84
|
+
*/
|
|
85
|
+
e2e?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Run the workspace delegation queue on this connector (claim → execute →
|
|
88
|
+
* complete, see delegation-runner.ts). Off by default: delegations are
|
|
89
|
+
* workspace-wide and claimed first-come-first-served, so with several
|
|
90
|
+
* connectors running only the one(s) the user explicitly opted in should
|
|
91
|
+
* race for them.
|
|
92
|
+
*/
|
|
93
|
+
delegations?: boolean;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Who this connector is: the stable-id prefixes that key its sessions and the
|
|
97
|
+
* default display-name prefix. Every connector picks its own (Claude Code uses
|
|
98
|
+
* cc/ccs), so two runtimes in the same directory never collide.
|
|
99
|
+
*/
|
|
100
|
+
export interface ConnectorIdentity {
|
|
101
|
+
/** Prefix for the derived instance id (e.g. "cc"). */
|
|
102
|
+
idPrefix: string;
|
|
103
|
+
/** Prefix for the derived runtime-session id (e.g. "ccs"). */
|
|
104
|
+
sessionIdPrefix: string;
|
|
105
|
+
/** Human prefix for the scratchpad display name (e.g. "Claude Code"). */
|
|
106
|
+
displayNamePrefix: string;
|
|
107
|
+
/** Where the connector reads file config from — used only in the missing-config error message. */
|
|
108
|
+
configPathHint?: string;
|
|
109
|
+
}
|
|
110
|
+
export declare function sanitizeId(raw: string): string;
|
|
111
|
+
/**
|
|
112
|
+
* Deterministic id from a seed (host + cwd), so the same project directory
|
|
113
|
+
* always maps back to the same Threa scratchpad across runtime restarts —
|
|
114
|
+
* no on-disk session state to keep in sync.
|
|
115
|
+
*/
|
|
116
|
+
export declare function deriveStableId(prefix: string, seed: string): string;
|
|
117
|
+
export declare function defaultDisplayName(cwd: string, prefix: string, override?: string): string;
|
|
118
|
+
export interface RawConfig {
|
|
119
|
+
baseUrl?: unknown;
|
|
120
|
+
workspaceId?: unknown;
|
|
121
|
+
apiKey?: unknown;
|
|
122
|
+
displayName?: unknown;
|
|
123
|
+
defaultLabel?: unknown;
|
|
124
|
+
coldStartIfArchived?: unknown;
|
|
125
|
+
coldStartIfMissing?: unknown;
|
|
126
|
+
expectedRootStreamId?: unknown;
|
|
127
|
+
permissionRelay?: unknown;
|
|
128
|
+
pollMs?: unknown;
|
|
129
|
+
idleTimeoutMs?: unknown;
|
|
130
|
+
instanceId?: unknown;
|
|
131
|
+
runtimeSessionId?: unknown;
|
|
132
|
+
bikPath?: unknown;
|
|
133
|
+
keyScope?: unknown;
|
|
134
|
+
keyStore?: unknown;
|
|
135
|
+
keyDir?: unknown;
|
|
136
|
+
e2e?: unknown;
|
|
137
|
+
sealedFullTrace?: unknown;
|
|
138
|
+
traceMode?: unknown;
|
|
139
|
+
delegations?: unknown;
|
|
140
|
+
}
|
|
141
|
+
export declare function parseConfigFile(text: string): RawConfig;
|
|
142
|
+
export interface LoadConfigInput {
|
|
143
|
+
env: Record<string, string | undefined>;
|
|
144
|
+
cwd: string;
|
|
145
|
+
hostname: string;
|
|
146
|
+
file?: RawConfig;
|
|
147
|
+
}
|
|
148
|
+
export type LoadConfigResult = {
|
|
149
|
+
config: RemoteSessionConfig;
|
|
150
|
+
} | {
|
|
151
|
+
error: string;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Pure config resolver: file values are the base, environment variables win.
|
|
155
|
+
* Kept side-effect-free so it can be unit-tested without touching disk/env.
|
|
156
|
+
*/
|
|
157
|
+
export declare function loadConfig(input: LoadConfigInput, identity: ConnectorIdentity): LoadConfigResult;
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { RemoteSession, DecisionAbandonedError, parseSessionControlCommand, isSessionControlInvocation, formatInvocationContent, buildSteerContent, effectiveRuntimeManifest, supportedCapabilitiesFor, claimCapabilitiesFor, runtimeCapabilitiesFor, SESSION_CONTROL_CAPABILITY, STEER_SETTLE_MS, COMPLETED_TURN_MEMORY, type DecisionOutcome, type DecisionRequestInput, type DeliveredTurn, type HandedOffCommandClaim, type ModelSuggestionInfo, type RemoteSessionDelegate, type RemoteSessionOptions, type RemoteSessionStatusSnapshot, type RuntimeDescriptor, type RuntimePresenceReport, type SendResult, type SessionControlActuator, type SessionControlInvocationContext, type ShutdownOptions, type SpawnRuntimeInfo, } from "./session.js";
|
|
2
|
+
export { ThreaClient, ThreaApiError, type AttachmentSummary, type ClaimedInvocation, type ExternalHistoryMessage, type RuntimeSessionLink, type StreamMessageSummary, type ThreaClientOptions, } from "./client.js";
|
|
3
|
+
export { TRACE_MODES, loadConfig, parseConfigFile, sanitizeId, deriveStableId, defaultDisplayName, type ConnectorIdentity, type LoadConfigInput, type LoadConfigResult, type RawConfig, type RemoteSessionConfig, type TraceMode, } from "./identity.js";
|
|
4
|
+
export { readConfigFile, writeFileAtomic } from "./config-file.js";
|
|
5
|
+
export { downloadInboundAttachments, formatInboundAttachmentManifest, uploadReplyAttachments, extractAttachmentDirectives, guessMimeType, ATTACH_DIRECTIVE_RE, ATTACHMENT_DIR, type DownloadedAttachment, type SelectedAttachment, } from "./attachments.js";
|
|
6
|
+
export { wireLifecycle, type LifecycleOptions, type LifecycleProcess } from "./lifecycle.js";
|
|
7
|
+
export { DelegationClient, type DelegationClientOptions, type DelegationSummary, type InspectedDelegation, type ClaimedDelegation, } from "./delegation-client.js";
|
|
8
|
+
export { DelegationRunner, type DelegationRunnerOptions, type DelegationExecutor, type DelegationExecutorContext, } from "./delegation-runner.js";
|
|
9
|
+
export { toolTraceContent, type ToolTraceSection, type ToolTraceSectionLabel } from "./tool-trace.js";
|
|
10
|
+
export type { DecisionRequest, StepFrame } from "@threahq/bot-runtime-client";
|