@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.
@@ -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";