@dejima/sdk 0.5.1
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/README.md +99 -0
- package/dist/client.d.ts +239 -0
- package/dist/client.js +504 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +16 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/session.d.ts +70 -0
- package/dist/session.js +144 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @dejima/sdk — TypeScript/JavaScript client
|
|
2
|
+
|
|
3
|
+
Thin client for the [Dejima](https://dejima.tech/) API: run a fleet of
|
|
4
|
+
AI coding agents on hardware you own. Mirrors the
|
|
5
|
+
[Python client](https://pypi.org/project/dejima-sdk/).
|
|
6
|
+
|
|
7
|
+
> **Alpha (0.x).** The API is stable in shape (`v1/`-prefixed) but fields may
|
|
8
|
+
> change until `1.0`. The REST layer mirrors
|
|
9
|
+
> [`openapi.yaml`](https://github.com/aoos/dejima/blob/master/openapi.yaml); the
|
|
10
|
+
> only hand-written piece is the PTY `Session` helper.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @dejima/sdk
|
|
16
|
+
# attach() uses the global WebSocket (Node 22+/browser) when present,
|
|
17
|
+
# else falls back to the optional `ws` package:
|
|
18
|
+
npm install ws # only for Node < 22 with token auth
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Node 18+ (uses the global `fetch` and `AbortController`). ESM-only.
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { Client } from "@dejima/sdk";
|
|
27
|
+
|
|
28
|
+
// host/token from $DEJIMA_HOST and $DEJIMA_TOKEN, or pass explicitly:
|
|
29
|
+
const dj = new Client({ host: "100.84.12.7:7273" });
|
|
30
|
+
|
|
31
|
+
const isl = await dj.createIsland("git@github.com:you/foo.git", { agent: "claude-code" });
|
|
32
|
+
console.log(isl.name, isl.state);
|
|
33
|
+
|
|
34
|
+
await dj.addAgent(isl.name, { type: "codex" }); // second agent, own worktree
|
|
35
|
+
|
|
36
|
+
const out = await dj.exec(isl.name, ["git", "status", "--short"]);
|
|
37
|
+
console.log(out.stdout, "exit", out.exit_code);
|
|
38
|
+
|
|
39
|
+
for (const i of await dj.listIslands()) {
|
|
40
|
+
console.log(i.name, i.state, (i.agents ?? []).length, "agents");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(await dj.overview()); // daemon health, VM memory, rollup
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Interactive sessions
|
|
47
|
+
|
|
48
|
+
`attach()` opens the multi-attach PTY stream. The daemon speaks a small
|
|
49
|
+
JSON-envelope protocol over the WebSocket — `Session` hides that, so you deal in
|
|
50
|
+
raw `Uint8Array`. Pull-based (`recv`) or event-based (`onData`):
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const s = await dj.attach(isl.name); // or { agent: "p2" }
|
|
54
|
+
s.resize(40, 120);
|
|
55
|
+
s.sendText("ls -la\n");
|
|
56
|
+
|
|
57
|
+
// pull style
|
|
58
|
+
let chunk: Uint8Array | null;
|
|
59
|
+
while ((chunk = await s.recv()) !== null) {
|
|
60
|
+
process.stdout.write(chunk);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// or event style
|
|
64
|
+
s.onData((bytes) => process.stdout.write(bytes));
|
|
65
|
+
s.onClose(() => console.log("session ended"));
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`dj.attachTerminal("t1")` does the same for an operator host terminal.
|
|
69
|
+
|
|
70
|
+
> Browser note: the global `WebSocket` cannot set request headers, so
|
|
71
|
+
> token-authenticated attach needs Node with the `ws` package (or a
|
|
72
|
+
> `wsFactory` you pass to `new Client(...)`). Operator (no-token) access works
|
|
73
|
+
> anywhere.
|
|
74
|
+
|
|
75
|
+
## API coverage
|
|
76
|
+
|
|
77
|
+
The client covers the full v1 surface: islands (list/create/get/update/delete,
|
|
78
|
+
hibernate/wake/reset/upgrade, clone, resources, workspace-ready, events), agents
|
|
79
|
+
(list/add/get/update/remove, configure), exec & files, Port broker, capability
|
|
80
|
+
broker, MCP broker (grants + `mcpCall`), credentials (Claude/GitHub/providers),
|
|
81
|
+
operator tokens (create/list/revoke), webhooks, team activity feed, daemon (overview/agent-types/
|
|
82
|
+
healthz/audit with filters + jsonl/csv export/clients/sessions-revoke/panic/
|
|
83
|
+
admin-update/image-build/ssh keys), host terminals, and the PTY `Session`.
|
|
84
|
+
|
|
85
|
+
Every non-2xx response rejects with a `DejimaError` (`.status`, `.description`).
|
|
86
|
+
|
|
87
|
+
## Auth
|
|
88
|
+
|
|
89
|
+
- **Operator** (unix socket / tailnet) needs no token.
|
|
90
|
+
- **Autonomy path** uses a per-island bearer token — set `DEJIMA_TOKEN` or pass
|
|
91
|
+
`{ token }` to the constructor.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm install
|
|
97
|
+
npm run build # tsc -> dist/
|
|
98
|
+
npm test # tsx --test (no daemon required)
|
|
99
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { Session, WebSocketLike } from "./session.js";
|
|
2
|
+
export interface ClientOptions {
|
|
3
|
+
/** `HOST:PORT` of the daemon (default `$DEJIMA_HOST` or 127.0.0.1:7273). */
|
|
4
|
+
host?: string;
|
|
5
|
+
/** Per-island bearer token (default `$DEJIMA_TOKEN`). */
|
|
6
|
+
token?: string;
|
|
7
|
+
/** `http` (default) or `https`. */
|
|
8
|
+
scheme?: "http" | "https";
|
|
9
|
+
/** Per-request timeout in milliseconds (default 30000). */
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
/** Override the `fetch` implementation (for testing or older runtimes). */
|
|
12
|
+
fetch?: typeof fetch;
|
|
13
|
+
/** Override how the PTY WebSocket is opened (for testing or the browser). */
|
|
14
|
+
wsFactory?: (url: string, opts: {
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
}) => WebSocketLike;
|
|
17
|
+
}
|
|
18
|
+
/** A client for one Dejima daemon. Mirrors the Python `dejima.Client`. */
|
|
19
|
+
export declare class Client {
|
|
20
|
+
readonly host: string;
|
|
21
|
+
readonly token?: string;
|
|
22
|
+
readonly scheme: "http" | "https";
|
|
23
|
+
readonly base: string;
|
|
24
|
+
private readonly timeoutMs;
|
|
25
|
+
private readonly fetchImpl;
|
|
26
|
+
private readonly wsFactory?;
|
|
27
|
+
constructor(opts?: ClientOptions);
|
|
28
|
+
private url;
|
|
29
|
+
private request;
|
|
30
|
+
private json;
|
|
31
|
+
private seg;
|
|
32
|
+
private island;
|
|
33
|
+
/** All islands, most-recently-used first. */
|
|
34
|
+
listIslands(): Promise<any[]>;
|
|
35
|
+
/** Provision an island. `repo` is a git URL or local path. */
|
|
36
|
+
createIsland(repo: string, opts?: {
|
|
37
|
+
agent?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
image?: string;
|
|
40
|
+
cmd?: string;
|
|
41
|
+
resources?: Record<string, unknown>;
|
|
42
|
+
agents?: Array<Record<string, unknown>>;
|
|
43
|
+
role?: string;
|
|
44
|
+
githubIdentity?: string;
|
|
45
|
+
seedPath?: string;
|
|
46
|
+
owner?: string;
|
|
47
|
+
tags?: Record<string, string>;
|
|
48
|
+
}): Promise<any>;
|
|
49
|
+
/** Island detail (agents, presence, cpu/mem, git, health, resources, disk). */
|
|
50
|
+
getIsland(name: string): Promise<any>;
|
|
51
|
+
/** Update the island's cosmetic display title (`""` clears it). */
|
|
52
|
+
updateIsland(name: string, title: string): Promise<any>;
|
|
53
|
+
/** Purge an island. `force` bypasses the unpushed-work guard. */
|
|
54
|
+
deleteIsland(name: string, force?: boolean): Promise<void>;
|
|
55
|
+
/** Stop the container, preserving volumes. */
|
|
56
|
+
hibernate(name: string): Promise<any>;
|
|
57
|
+
/** Start a hibernated island's container against existing volumes. */
|
|
58
|
+
wake(name: string): Promise<any>;
|
|
59
|
+
/** Clear agent on-disk state (preserves the workspace). */
|
|
60
|
+
reset(name: string): Promise<any>;
|
|
61
|
+
/** Recreate the container against the current island image (both volumes preserved). */
|
|
62
|
+
upgrade(name: string): Promise<any>;
|
|
63
|
+
/** Copy an island (workspace + home volumes; Port grants dropped). */
|
|
64
|
+
cloneIsland(name: string, newName: string): Promise<any>;
|
|
65
|
+
/** Update memory limit (live) and/or OOM priority (needs a recreate). */
|
|
66
|
+
setResources(name: string, opts: {
|
|
67
|
+
memory?: string;
|
|
68
|
+
oomPriority?: number;
|
|
69
|
+
}): Promise<any>;
|
|
70
|
+
/** Whether the island's repo clone has landed in /workspace yet. */
|
|
71
|
+
workspaceReady(name: string): Promise<boolean>;
|
|
72
|
+
/** The island's recent event log (newest first). */
|
|
73
|
+
islandEvents(name: string): Promise<any[]>;
|
|
74
|
+
listAgents(name: string): Promise<any[]>;
|
|
75
|
+
/** Add an agent (its own worktree + tmux session). */
|
|
76
|
+
addAgent(name: string, opts?: {
|
|
77
|
+
type?: string;
|
|
78
|
+
label?: string;
|
|
79
|
+
cmd?: string;
|
|
80
|
+
provider?: string;
|
|
81
|
+
model?: string;
|
|
82
|
+
}): Promise<any>;
|
|
83
|
+
getAgent(name: string, agentId: string): Promise<any>;
|
|
84
|
+
/** Rename an agent's cosmetic label (`""` clears it). */
|
|
85
|
+
updateAgent(name: string, agentId: string, label: string): Promise<any>;
|
|
86
|
+
/** Remove a non-primary agent (kills its session, prunes its worktree). */
|
|
87
|
+
removeAgent(name: string, agentId: string): Promise<void>;
|
|
88
|
+
/** Set an agent's LLM provider/model (key-requiring frameworks only). */
|
|
89
|
+
configureAgent(name: string, agentId: string, opts: {
|
|
90
|
+
provider?: string;
|
|
91
|
+
model?: string;
|
|
92
|
+
}): Promise<any>;
|
|
93
|
+
/** Run a one-shot command. Returns `{stdout, stderr, exit_code}`. */
|
|
94
|
+
exec(name: string, cmd: string[]): Promise<any>;
|
|
95
|
+
/** Read a file from the island (defaults to the primary worktree). */
|
|
96
|
+
readFile(name: string, path: string): Promise<Uint8Array>;
|
|
97
|
+
/** Write a file into the island. */
|
|
98
|
+
writeFile(name: string, path: string, data: Uint8Array | string): Promise<void>;
|
|
99
|
+
/** Fetch container/agent logs as text. */
|
|
100
|
+
logs(name: string, opts?: {
|
|
101
|
+
agent?: string;
|
|
102
|
+
}): Promise<string>;
|
|
103
|
+
listPortScopes(name: string): Promise<any>;
|
|
104
|
+
grantPortScope(name: string, hostPath: string, mode?: "ro" | "rw"): Promise<any>;
|
|
105
|
+
revokePortScope(name: string, scope: string): Promise<void>;
|
|
106
|
+
portIntake(name: string, scope: string, srcRel: string, dest?: string): Promise<any>;
|
|
107
|
+
portExport(name: string, src: string): Promise<any>;
|
|
108
|
+
portWrite(name: string, scope: string, src: string, destRel: string): Promise<any>;
|
|
109
|
+
listCapabilityGrants(name: string): Promise<any>;
|
|
110
|
+
grantCapability(name: string, target: string): Promise<any>;
|
|
111
|
+
revokeCapability(name: string, target: string): Promise<void>;
|
|
112
|
+
/** Invoke a granted capability. Operators name `island`; an in-island token is pinned. */
|
|
113
|
+
executeCapability(target: string, opts?: {
|
|
114
|
+
island?: string;
|
|
115
|
+
args?: Record<string, string>;
|
|
116
|
+
}): Promise<any>;
|
|
117
|
+
listMcpGrants(name: string): Promise<any>;
|
|
118
|
+
grantMcp(name: string, server: string): Promise<any>;
|
|
119
|
+
revokeMcp(name: string, server: string): Promise<void>;
|
|
120
|
+
/** Invoke a JSON-RPC `method` on a granted MCP server (deny-all; ledgered). */
|
|
121
|
+
mcpCall(server: string, method: string, opts?: {
|
|
122
|
+
island?: string;
|
|
123
|
+
params?: unknown;
|
|
124
|
+
}): Promise<any>;
|
|
125
|
+
pushClaudeCredentials(credentialsJson: string): Promise<void>;
|
|
126
|
+
claudeCredentialsStatus(): Promise<any>;
|
|
127
|
+
listGitHubIdentities(): Promise<any>;
|
|
128
|
+
putGitHubIdentity(name: string, opts: {
|
|
129
|
+
login: string;
|
|
130
|
+
token: string;
|
|
131
|
+
id?: number;
|
|
132
|
+
host?: string;
|
|
133
|
+
default?: boolean;
|
|
134
|
+
}): Promise<any>;
|
|
135
|
+
deleteGitHubIdentity(name: string): Promise<any>;
|
|
136
|
+
githubRepos(name: string): Promise<any>;
|
|
137
|
+
listProviders(): Promise<any>;
|
|
138
|
+
putProvider(provider: string, opts: {
|
|
139
|
+
apiKey: string;
|
|
140
|
+
baseUrl?: string;
|
|
141
|
+
envVar?: string;
|
|
142
|
+
default?: boolean;
|
|
143
|
+
}): Promise<any>;
|
|
144
|
+
deleteProvider(provider: string): Promise<any>;
|
|
145
|
+
listTokens(): Promise<any>;
|
|
146
|
+
/**
|
|
147
|
+
* Mint an operator bearer token. `role` is `owner`/`operator`/`viewer`;
|
|
148
|
+
* `islands` scopes it (empty = all). The response carries the bearer `secret` —
|
|
149
|
+
* returned ONCE and never stored in the clear.
|
|
150
|
+
*/
|
|
151
|
+
createToken(role: "owner" | "operator" | "viewer", opts?: {
|
|
152
|
+
label?: string;
|
|
153
|
+
islands?: string[];
|
|
154
|
+
}): Promise<any>;
|
|
155
|
+
revokeToken(tokenId: string): Promise<void>;
|
|
156
|
+
listSubscriptions(): Promise<any[]>;
|
|
157
|
+
subscribeWebhook(url: string, opts?: {
|
|
158
|
+
secret?: string;
|
|
159
|
+
events?: string[];
|
|
160
|
+
}): Promise<any>;
|
|
161
|
+
unsubscribeWebhook(subscriptionId: string): Promise<void>;
|
|
162
|
+
overview(): Promise<any>;
|
|
163
|
+
agentTypes(): Promise<any>;
|
|
164
|
+
/** Reachability probe; resolves true when the daemon answers. */
|
|
165
|
+
healthz(): Promise<boolean>;
|
|
166
|
+
/**
|
|
167
|
+
* The audit ledger (brokered ops + `api.request` + lifecycle) + a hash-chain
|
|
168
|
+
* verdict. Filters compose; `limit` tails the filtered result. `since`/`until`
|
|
169
|
+
* are RFC3339; `typePrefix` matches the entry-type prefix. With
|
|
170
|
+
* `format: "jsonl" | "csv"` the daemon streams a text export — this resolves
|
|
171
|
+
* that string instead of the parsed envelope.
|
|
172
|
+
*/
|
|
173
|
+
audit(opts?: {
|
|
174
|
+
limit?: number;
|
|
175
|
+
since?: string;
|
|
176
|
+
until?: string;
|
|
177
|
+
island?: string;
|
|
178
|
+
typePrefix?: string;
|
|
179
|
+
actor?: string;
|
|
180
|
+
decision?: "allowed" | "denied";
|
|
181
|
+
format?: "json" | "jsonl" | "csv";
|
|
182
|
+
}): Promise<any>;
|
|
183
|
+
/**
|
|
184
|
+
* The team activity feed — a curated, human-rendered timeline over the audit
|
|
185
|
+
* ledger (who launched what, which agent did what), newest first. `kind` is
|
|
186
|
+
* `lifecycle`/`broker`/`system`; `since`/`until` are RFC3339.
|
|
187
|
+
*/
|
|
188
|
+
activity(opts?: {
|
|
189
|
+
actor?: string;
|
|
190
|
+
island?: string;
|
|
191
|
+
owner?: string;
|
|
192
|
+
kind?: "lifecycle" | "broker" | "system";
|
|
193
|
+
decision?: "allowed" | "denied";
|
|
194
|
+
since?: string;
|
|
195
|
+
until?: string;
|
|
196
|
+
limit?: number;
|
|
197
|
+
}): Promise<any>;
|
|
198
|
+
/** Recent attach/detach events across every island (newest first). */
|
|
199
|
+
clientHistory(): Promise<any[]>;
|
|
200
|
+
/** Drop every active websocket client across every island. */
|
|
201
|
+
revokeSessions(): Promise<any>;
|
|
202
|
+
panicStatus(): Promise<any>;
|
|
203
|
+
panic(opts?: {
|
|
204
|
+
reason?: string;
|
|
205
|
+
}): Promise<any>;
|
|
206
|
+
unpanic(): Promise<any>;
|
|
207
|
+
adminUpdate(opts?: {
|
|
208
|
+
execute?: boolean;
|
|
209
|
+
force?: boolean;
|
|
210
|
+
}): Promise<any>;
|
|
211
|
+
/** Rebuild the island image; resolves the combined build output as text. */
|
|
212
|
+
imageBuild(): Promise<string>;
|
|
213
|
+
authorizeSshKey(publicKey: string): Promise<any>;
|
|
214
|
+
listSshKeys(): Promise<any>;
|
|
215
|
+
listTerminals(): Promise<any>;
|
|
216
|
+
createTerminal(opts?: {
|
|
217
|
+
label?: string;
|
|
218
|
+
}): Promise<any>;
|
|
219
|
+
deleteTerminal(terminalId: string): Promise<void>;
|
|
220
|
+
relabelTerminal(terminalId: string, label: string): Promise<any>;
|
|
221
|
+
/** The WebSocket URL for an interactive PTY session (multi-attach). */
|
|
222
|
+
sessionUrl(name: string, opts?: {
|
|
223
|
+
agent?: string;
|
|
224
|
+
}): string;
|
|
225
|
+
/** The WebSocket URL for a host terminal's PTY session. */
|
|
226
|
+
terminalSessionUrl(terminalId: string, opts?: {
|
|
227
|
+
label?: string;
|
|
228
|
+
}): string;
|
|
229
|
+
private wsBase;
|
|
230
|
+
/** Open an island's PTY session stream. Resolves once the socket is open. */
|
|
231
|
+
attach(name: string, opts?: {
|
|
232
|
+
agent?: string;
|
|
233
|
+
}): Promise<Session>;
|
|
234
|
+
/** Open a host terminal's PTY session stream. */
|
|
235
|
+
attachTerminal(terminalId: string, opts?: {
|
|
236
|
+
label?: string;
|
|
237
|
+
}): Promise<Session>;
|
|
238
|
+
private openSession;
|
|
239
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { DejimaError } from "./errors.js";
|
|
2
|
+
import { Session } from "./session.js";
|
|
3
|
+
const DEFAULT_HOST = "127.0.0.1:7273";
|
|
4
|
+
/** Drop `undefined` values so optional fields are simply omitted from the body. */
|
|
5
|
+
function clean(obj) {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const [k, v] of Object.entries(obj))
|
|
8
|
+
if (v !== undefined)
|
|
9
|
+
out[k] = v;
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
function env(name) {
|
|
13
|
+
const g = globalThis;
|
|
14
|
+
return typeof g.process !== "undefined" ? g.process.env?.[name] : undefined;
|
|
15
|
+
}
|
|
16
|
+
/** A client for one Dejima daemon. Mirrors the Python `dejima.Client`. */
|
|
17
|
+
export class Client {
|
|
18
|
+
host;
|
|
19
|
+
token;
|
|
20
|
+
scheme;
|
|
21
|
+
base;
|
|
22
|
+
timeoutMs;
|
|
23
|
+
fetchImpl;
|
|
24
|
+
wsFactory;
|
|
25
|
+
constructor(opts = {}) {
|
|
26
|
+
this.host = opts.host ?? env("DEJIMA_HOST") ?? DEFAULT_HOST;
|
|
27
|
+
this.token = opts.token ?? env("DEJIMA_TOKEN");
|
|
28
|
+
this.scheme = opts.scheme ?? "http";
|
|
29
|
+
this.base = `${this.scheme}://${this.host}`;
|
|
30
|
+
this.timeoutMs = opts.timeoutMs ?? 30000;
|
|
31
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
32
|
+
if (!f)
|
|
33
|
+
throw new Error("no fetch available; pass options.fetch");
|
|
34
|
+
this.fetchImpl = f.bind(globalThis);
|
|
35
|
+
this.wsFactory = opts.wsFactory;
|
|
36
|
+
}
|
|
37
|
+
// ----- low-level ------------------------------------------------------
|
|
38
|
+
url(path, query) {
|
|
39
|
+
let u = this.base + path;
|
|
40
|
+
if (query) {
|
|
41
|
+
const qs = Object.entries(query)
|
|
42
|
+
.filter(([, v]) => v !== undefined)
|
|
43
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
44
|
+
.join("&");
|
|
45
|
+
if (qs)
|
|
46
|
+
u += `?${qs}`;
|
|
47
|
+
}
|
|
48
|
+
return u;
|
|
49
|
+
}
|
|
50
|
+
async request(method, path, opts = {}) {
|
|
51
|
+
const headers = {};
|
|
52
|
+
if (this.token)
|
|
53
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
54
|
+
if (opts.accept)
|
|
55
|
+
headers["Accept"] = opts.accept;
|
|
56
|
+
let body = opts.body;
|
|
57
|
+
if (opts.json !== undefined) {
|
|
58
|
+
headers["Content-Type"] = "application/json";
|
|
59
|
+
body = JSON.stringify(opts.json);
|
|
60
|
+
}
|
|
61
|
+
const ctrl = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
63
|
+
let resp;
|
|
64
|
+
try {
|
|
65
|
+
resp = await this.fetchImpl(this.url(path, opts.query), { method, headers, body, signal: ctrl.signal });
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
}
|
|
70
|
+
if (!resp.ok)
|
|
71
|
+
throw new DejimaError(resp.status, await errorMessage(resp));
|
|
72
|
+
return resp;
|
|
73
|
+
}
|
|
74
|
+
async json(method, path, opts = {}) {
|
|
75
|
+
const r = await this.request(method, path, opts);
|
|
76
|
+
const text = await r.text();
|
|
77
|
+
return (text ? JSON.parse(text) : undefined);
|
|
78
|
+
}
|
|
79
|
+
seg(s) {
|
|
80
|
+
return encodeURIComponent(s);
|
|
81
|
+
}
|
|
82
|
+
island(name) {
|
|
83
|
+
return `/v1/islands/${this.seg(name)}`;
|
|
84
|
+
}
|
|
85
|
+
// ===== islands ========================================================
|
|
86
|
+
/** All islands, most-recently-used first. */
|
|
87
|
+
listIslands() {
|
|
88
|
+
return this.json("GET", "/v1/islands");
|
|
89
|
+
}
|
|
90
|
+
/** Provision an island. `repo` is a git URL or local path. */
|
|
91
|
+
createIsland(repo, opts = {}) {
|
|
92
|
+
const body = clean({
|
|
93
|
+
repo,
|
|
94
|
+
agent: opts.agent,
|
|
95
|
+
name: opts.name,
|
|
96
|
+
image: opts.image,
|
|
97
|
+
cmd: opts.cmd,
|
|
98
|
+
resources: opts.resources,
|
|
99
|
+
agents: opts.agents,
|
|
100
|
+
role: opts.role,
|
|
101
|
+
github_identity: opts.githubIdentity,
|
|
102
|
+
seed_path: opts.seedPath,
|
|
103
|
+
owner: opts.owner,
|
|
104
|
+
tags: opts.tags,
|
|
105
|
+
});
|
|
106
|
+
return this.json("POST", "/v1/islands", { json: body });
|
|
107
|
+
}
|
|
108
|
+
/** Island detail (agents, presence, cpu/mem, git, health, resources, disk). */
|
|
109
|
+
getIsland(name) {
|
|
110
|
+
return this.json("GET", this.island(name));
|
|
111
|
+
}
|
|
112
|
+
/** Update the island's cosmetic display title (`""` clears it). */
|
|
113
|
+
updateIsland(name, title) {
|
|
114
|
+
return this.json("PATCH", this.island(name), { json: { title } });
|
|
115
|
+
}
|
|
116
|
+
/** Purge an island. `force` bypasses the unpushed-work guard. */
|
|
117
|
+
async deleteIsland(name, force = false) {
|
|
118
|
+
await this.request("DELETE", this.island(name), { query: force ? { force: true } : undefined });
|
|
119
|
+
}
|
|
120
|
+
/** Stop the container, preserving volumes. */
|
|
121
|
+
hibernate(name) {
|
|
122
|
+
return this.json("POST", `${this.island(name)}/hibernate`);
|
|
123
|
+
}
|
|
124
|
+
/** Start a hibernated island's container against existing volumes. */
|
|
125
|
+
wake(name) {
|
|
126
|
+
return this.json("POST", `${this.island(name)}/wake`);
|
|
127
|
+
}
|
|
128
|
+
/** Clear agent on-disk state (preserves the workspace). */
|
|
129
|
+
reset(name) {
|
|
130
|
+
return this.json("POST", `${this.island(name)}/reset`);
|
|
131
|
+
}
|
|
132
|
+
/** Recreate the container against the current island image (both volumes preserved). */
|
|
133
|
+
upgrade(name) {
|
|
134
|
+
return this.json("POST", `${this.island(name)}/upgrade`);
|
|
135
|
+
}
|
|
136
|
+
/** Copy an island (workspace + home volumes; Port grants dropped). */
|
|
137
|
+
cloneIsland(name, newName) {
|
|
138
|
+
return this.json("POST", `${this.island(name)}/clone`, { json: { new_name: newName } });
|
|
139
|
+
}
|
|
140
|
+
/** Update memory limit (live) and/or OOM priority (needs a recreate). */
|
|
141
|
+
setResources(name, opts) {
|
|
142
|
+
const body = {};
|
|
143
|
+
if (opts.memory !== undefined)
|
|
144
|
+
body.memory = opts.memory;
|
|
145
|
+
if (opts.oomPriority !== undefined)
|
|
146
|
+
body.oom_priority = opts.oomPriority;
|
|
147
|
+
return this.json("PUT", `${this.island(name)}/resources`, { json: body });
|
|
148
|
+
}
|
|
149
|
+
/** Whether the island's repo clone has landed in /workspace yet. */
|
|
150
|
+
async workspaceReady(name) {
|
|
151
|
+
const r = await this.json("GET", `${this.island(name)}/workspace-ready`);
|
|
152
|
+
return Boolean(r?.ready);
|
|
153
|
+
}
|
|
154
|
+
/** The island's recent event log (newest first). */
|
|
155
|
+
islandEvents(name) {
|
|
156
|
+
return this.json("GET", `${this.island(name)}/events`);
|
|
157
|
+
}
|
|
158
|
+
// ===== agents =========================================================
|
|
159
|
+
listAgents(name) {
|
|
160
|
+
return this.json("GET", `${this.island(name)}/agents`);
|
|
161
|
+
}
|
|
162
|
+
/** Add an agent (its own worktree + tmux session). */
|
|
163
|
+
addAgent(name, opts = {}) {
|
|
164
|
+
return this.json("POST", `${this.island(name)}/agents`, { json: clean({ ...opts }) });
|
|
165
|
+
}
|
|
166
|
+
getAgent(name, agentId) {
|
|
167
|
+
return this.json("GET", `${this.island(name)}/agents/${this.seg(agentId)}`);
|
|
168
|
+
}
|
|
169
|
+
/** Rename an agent's cosmetic label (`""` clears it). */
|
|
170
|
+
updateAgent(name, agentId, label) {
|
|
171
|
+
return this.json("PATCH", `${this.island(name)}/agents/${this.seg(agentId)}`, { json: { label } });
|
|
172
|
+
}
|
|
173
|
+
/** Remove a non-primary agent (kills its session, prunes its worktree). */
|
|
174
|
+
async removeAgent(name, agentId) {
|
|
175
|
+
await this.request("DELETE", `${this.island(name)}/agents/${this.seg(agentId)}`);
|
|
176
|
+
}
|
|
177
|
+
/** Set an agent's LLM provider/model (key-requiring frameworks only). */
|
|
178
|
+
configureAgent(name, agentId, opts) {
|
|
179
|
+
const body = {};
|
|
180
|
+
if (opts.provider !== undefined)
|
|
181
|
+
body.provider = opts.provider;
|
|
182
|
+
if (opts.model !== undefined)
|
|
183
|
+
body.model = opts.model;
|
|
184
|
+
return this.json("PATCH", `${this.island(name)}/agents/${this.seg(agentId)}/config`, { json: body });
|
|
185
|
+
}
|
|
186
|
+
// ===== exec / files / logs -------------------------------------------
|
|
187
|
+
/** Run a one-shot command. Returns `{stdout, stderr, exit_code}`. */
|
|
188
|
+
exec(name, cmd) {
|
|
189
|
+
return this.json("POST", `${this.island(name)}/exec`, { json: { cmd } });
|
|
190
|
+
}
|
|
191
|
+
/** Read a file from the island (defaults to the primary worktree). */
|
|
192
|
+
async readFile(name, path) {
|
|
193
|
+
const r = await this.request("GET", `${this.island(name)}/files/${encodePath(path)}`);
|
|
194
|
+
return new Uint8Array(await r.arrayBuffer());
|
|
195
|
+
}
|
|
196
|
+
/** Write a file into the island. */
|
|
197
|
+
async writeFile(name, path, data) {
|
|
198
|
+
await this.request("PUT", `${this.island(name)}/files/${encodePath(path)}`, { body: data });
|
|
199
|
+
}
|
|
200
|
+
/** Fetch container/agent logs as text. */
|
|
201
|
+
async logs(name, opts = {}) {
|
|
202
|
+
const r = await this.request("GET", `${this.island(name)}/logs`, { query: clean({ agent: opts.agent }) });
|
|
203
|
+
return r.text();
|
|
204
|
+
}
|
|
205
|
+
// ===== port broker ----------------------------------------------------
|
|
206
|
+
listPortScopes(name) {
|
|
207
|
+
return this.json("GET", `${this.island(name)}/port/scopes`);
|
|
208
|
+
}
|
|
209
|
+
grantPortScope(name, hostPath, mode = "ro") {
|
|
210
|
+
return this.json("POST", `${this.island(name)}/port/scopes`, { json: { host_path: hostPath, mode } });
|
|
211
|
+
}
|
|
212
|
+
async revokePortScope(name, scope) {
|
|
213
|
+
await this.request("DELETE", `${this.island(name)}/port/scopes/${this.seg(scope)}`);
|
|
214
|
+
}
|
|
215
|
+
portIntake(name, scope, srcRel, dest) {
|
|
216
|
+
return this.json("POST", `${this.island(name)}/port/intake`, { json: clean({ scope, src_rel: srcRel, dest }) });
|
|
217
|
+
}
|
|
218
|
+
portExport(name, src) {
|
|
219
|
+
return this.json("POST", `${this.island(name)}/port/export`, { json: { src } });
|
|
220
|
+
}
|
|
221
|
+
portWrite(name, scope, src, destRel) {
|
|
222
|
+
return this.json("POST", `${this.island(name)}/port/write`, { json: { scope, src, dest_rel: destRel } });
|
|
223
|
+
}
|
|
224
|
+
// ===== capability broker ---------------------------------------------
|
|
225
|
+
listCapabilityGrants(name) {
|
|
226
|
+
return this.json("GET", `${this.island(name)}/capability/grants`);
|
|
227
|
+
}
|
|
228
|
+
grantCapability(name, target) {
|
|
229
|
+
return this.json("POST", `${this.island(name)}/capability/grants`, { json: { target } });
|
|
230
|
+
}
|
|
231
|
+
async revokeCapability(name, target) {
|
|
232
|
+
await this.request("DELETE", `${this.island(name)}/capability/grants/${this.seg(target)}`);
|
|
233
|
+
}
|
|
234
|
+
/** Invoke a granted capability. Operators name `island`; an in-island token is pinned. */
|
|
235
|
+
executeCapability(target, opts = {}) {
|
|
236
|
+
return this.json("POST", "/v1/capabilities/execute", { json: clean({ target, island: opts.island, args: opts.args }) });
|
|
237
|
+
}
|
|
238
|
+
// ===== MCP broker -----------------------------------------------------
|
|
239
|
+
listMcpGrants(name) {
|
|
240
|
+
return this.json("GET", `${this.island(name)}/mcp/grants`);
|
|
241
|
+
}
|
|
242
|
+
grantMcp(name, server) {
|
|
243
|
+
return this.json("POST", `${this.island(name)}/mcp/grants`, { json: { server } });
|
|
244
|
+
}
|
|
245
|
+
async revokeMcp(name, server) {
|
|
246
|
+
await this.request("DELETE", `${this.island(name)}/mcp/grants/${this.seg(server)}`);
|
|
247
|
+
}
|
|
248
|
+
/** Invoke a JSON-RPC `method` on a granted MCP server (deny-all; ledgered). */
|
|
249
|
+
mcpCall(server, method, opts = {}) {
|
|
250
|
+
return this.json("POST", "/v1/mcp/call", { json: clean({ server, method, island: opts.island, params: opts.params }) });
|
|
251
|
+
}
|
|
252
|
+
// ===== credentials ----------------------------------------------------
|
|
253
|
+
async pushClaudeCredentials(credentialsJson) {
|
|
254
|
+
await this.request("PUT", "/v1/credentials/claude", { json: { credentials_json: credentialsJson } });
|
|
255
|
+
}
|
|
256
|
+
claudeCredentialsStatus() {
|
|
257
|
+
return this.json("GET", "/v1/credentials/claude");
|
|
258
|
+
}
|
|
259
|
+
listGitHubIdentities() {
|
|
260
|
+
return this.json("GET", "/v1/credentials/github");
|
|
261
|
+
}
|
|
262
|
+
putGitHubIdentity(name, opts) {
|
|
263
|
+
const body = clean({ login: opts.login, token: opts.token, id: opts.id, host: opts.host, default: opts.default || undefined });
|
|
264
|
+
return this.json("PUT", `/v1/credentials/github/${this.seg(name)}`, { json: body });
|
|
265
|
+
}
|
|
266
|
+
deleteGitHubIdentity(name) {
|
|
267
|
+
return this.json("DELETE", `/v1/credentials/github/${this.seg(name)}`);
|
|
268
|
+
}
|
|
269
|
+
githubRepos(name) {
|
|
270
|
+
return this.json("GET", `/v1/credentials/github/${this.seg(name)}/repos`);
|
|
271
|
+
}
|
|
272
|
+
listProviders() {
|
|
273
|
+
return this.json("GET", "/v1/credentials/providers");
|
|
274
|
+
}
|
|
275
|
+
putProvider(provider, opts) {
|
|
276
|
+
const body = clean({ api_key: opts.apiKey, base_url: opts.baseUrl, env_var: opts.envVar, default: opts.default || undefined });
|
|
277
|
+
return this.json("PUT", `/v1/credentials/providers/${this.seg(provider)}`, { json: body });
|
|
278
|
+
}
|
|
279
|
+
deleteProvider(provider) {
|
|
280
|
+
return this.json("DELETE", `/v1/credentials/providers/${this.seg(provider)}`);
|
|
281
|
+
}
|
|
282
|
+
// ===== operator tokens (owner-only) ----------------------------------
|
|
283
|
+
listTokens() {
|
|
284
|
+
return this.json("GET", "/v1/tokens");
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Mint an operator bearer token. `role` is `owner`/`operator`/`viewer`;
|
|
288
|
+
* `islands` scopes it (empty = all). The response carries the bearer `secret` —
|
|
289
|
+
* returned ONCE and never stored in the clear.
|
|
290
|
+
*/
|
|
291
|
+
createToken(role, opts = {}) {
|
|
292
|
+
return this.json("POST", "/v1/tokens", { json: clean({ role, label: opts.label, islands: opts.islands }) });
|
|
293
|
+
}
|
|
294
|
+
async revokeToken(tokenId) {
|
|
295
|
+
await this.request("DELETE", `/v1/tokens/${this.seg(tokenId)}`);
|
|
296
|
+
}
|
|
297
|
+
// ===== events / webhooks ---------------------------------------------
|
|
298
|
+
listSubscriptions() {
|
|
299
|
+
return this.json("GET", "/v1/events/subscriptions");
|
|
300
|
+
}
|
|
301
|
+
subscribeWebhook(url, opts = {}) {
|
|
302
|
+
return this.json("POST", "/v1/events/subscribe", { json: clean({ url, secret: opts.secret, events: opts.events }) });
|
|
303
|
+
}
|
|
304
|
+
async unsubscribeWebhook(subscriptionId) {
|
|
305
|
+
await this.request("DELETE", `/v1/events/subscriptions/${this.seg(subscriptionId)}`);
|
|
306
|
+
}
|
|
307
|
+
// ===== daemon ---------------------------------------------------------
|
|
308
|
+
overview() {
|
|
309
|
+
return this.json("GET", "/v1/overview");
|
|
310
|
+
}
|
|
311
|
+
agentTypes() {
|
|
312
|
+
return this.json("GET", "/v1/agent-types");
|
|
313
|
+
}
|
|
314
|
+
/** Reachability probe; resolves true when the daemon answers. */
|
|
315
|
+
async healthz() {
|
|
316
|
+
try {
|
|
317
|
+
await this.request("GET", "/v1/healthz");
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
if (e instanceof DejimaError)
|
|
322
|
+
return false;
|
|
323
|
+
throw e;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The audit ledger (brokered ops + `api.request` + lifecycle) + a hash-chain
|
|
328
|
+
* verdict. Filters compose; `limit` tails the filtered result. `since`/`until`
|
|
329
|
+
* are RFC3339; `typePrefix` matches the entry-type prefix. With
|
|
330
|
+
* `format: "jsonl" | "csv"` the daemon streams a text export — this resolves
|
|
331
|
+
* that string instead of the parsed envelope.
|
|
332
|
+
*/
|
|
333
|
+
async audit(opts = {}) {
|
|
334
|
+
const query = clean({
|
|
335
|
+
limit: opts.limit,
|
|
336
|
+
since: opts.since,
|
|
337
|
+
until: opts.until,
|
|
338
|
+
island: opts.island,
|
|
339
|
+
type: opts.typePrefix,
|
|
340
|
+
actor: opts.actor,
|
|
341
|
+
decision: opts.decision,
|
|
342
|
+
format: opts.format,
|
|
343
|
+
});
|
|
344
|
+
if (opts.format === "jsonl" || opts.format === "csv") {
|
|
345
|
+
return (await this.request("GET", "/v1/audit", { query })).text();
|
|
346
|
+
}
|
|
347
|
+
return this.json("GET", "/v1/audit", { query });
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The team activity feed — a curated, human-rendered timeline over the audit
|
|
351
|
+
* ledger (who launched what, which agent did what), newest first. `kind` is
|
|
352
|
+
* `lifecycle`/`broker`/`system`; `since`/`until` are RFC3339.
|
|
353
|
+
*/
|
|
354
|
+
activity(opts = {}) {
|
|
355
|
+
return this.json("GET", "/v1/activity", {
|
|
356
|
+
query: clean({
|
|
357
|
+
actor: opts.actor,
|
|
358
|
+
island: opts.island,
|
|
359
|
+
owner: opts.owner,
|
|
360
|
+
kind: opts.kind,
|
|
361
|
+
decision: opts.decision,
|
|
362
|
+
since: opts.since,
|
|
363
|
+
until: opts.until,
|
|
364
|
+
limit: opts.limit,
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/** Recent attach/detach events across every island (newest first). */
|
|
369
|
+
clientHistory() {
|
|
370
|
+
return this.json("GET", "/v1/clients");
|
|
371
|
+
}
|
|
372
|
+
/** Drop every active websocket client across every island. */
|
|
373
|
+
revokeSessions() {
|
|
374
|
+
return this.json("POST", "/v1/sessions/revoke");
|
|
375
|
+
}
|
|
376
|
+
panicStatus() {
|
|
377
|
+
return this.json("GET", "/v1/panic");
|
|
378
|
+
}
|
|
379
|
+
panic(opts = {}) {
|
|
380
|
+
return this.json("POST", "/v1/panic", { json: clean({ reason: opts.reason }) });
|
|
381
|
+
}
|
|
382
|
+
unpanic() {
|
|
383
|
+
return this.json("DELETE", "/v1/panic");
|
|
384
|
+
}
|
|
385
|
+
adminUpdate(opts = {}) {
|
|
386
|
+
return this.json("POST", "/v1/admin/update", { json: { execute: opts.execute ?? false, force: opts.force ?? false } });
|
|
387
|
+
}
|
|
388
|
+
/** Rebuild the island image; resolves the combined build output as text. */
|
|
389
|
+
async imageBuild() {
|
|
390
|
+
const r = await this.request("POST", "/v1/image/build");
|
|
391
|
+
return r.text();
|
|
392
|
+
}
|
|
393
|
+
authorizeSshKey(publicKey) {
|
|
394
|
+
return this.json("POST", "/v1/ssh/account-keys", { json: { public_key: publicKey } });
|
|
395
|
+
}
|
|
396
|
+
listSshKeys() {
|
|
397
|
+
return this.json("GET", "/v1/ssh/account-keys");
|
|
398
|
+
}
|
|
399
|
+
// ===== host terminals (operator-only; requires --host-terminals) -----
|
|
400
|
+
listTerminals() {
|
|
401
|
+
return this.json("GET", "/v1/terminals");
|
|
402
|
+
}
|
|
403
|
+
createTerminal(opts = {}) {
|
|
404
|
+
return this.json("POST", "/v1/terminals", { json: clean({ label: opts.label }) });
|
|
405
|
+
}
|
|
406
|
+
async deleteTerminal(terminalId) {
|
|
407
|
+
await this.request("DELETE", `/v1/terminals/${this.seg(terminalId)}`);
|
|
408
|
+
}
|
|
409
|
+
relabelTerminal(terminalId, label) {
|
|
410
|
+
return this.json("PATCH", `/v1/terminals/${this.seg(terminalId)}`, { json: { label } });
|
|
411
|
+
}
|
|
412
|
+
// ===== session (WebSocket PTY) ---------------------------------------
|
|
413
|
+
/** The WebSocket URL for an interactive PTY session (multi-attach). */
|
|
414
|
+
sessionUrl(name, opts = {}) {
|
|
415
|
+
const base = this.wsBase();
|
|
416
|
+
return opts.agent
|
|
417
|
+
? `${base}${this.island(name)}/agents/${this.seg(opts.agent)}/session`
|
|
418
|
+
: `${base}${this.island(name)}/session`;
|
|
419
|
+
}
|
|
420
|
+
/** The WebSocket URL for a host terminal's PTY session. */
|
|
421
|
+
terminalSessionUrl(terminalId, opts = {}) {
|
|
422
|
+
let url = `${this.wsBase()}/v1/terminals/${this.seg(terminalId)}/session`;
|
|
423
|
+
if (opts.label)
|
|
424
|
+
url += `?label=${encodeURIComponent(opts.label)}`;
|
|
425
|
+
return url;
|
|
426
|
+
}
|
|
427
|
+
wsBase() {
|
|
428
|
+
return `${this.scheme === "https" ? "wss" : "ws"}://${this.host}`;
|
|
429
|
+
}
|
|
430
|
+
/** Open an island's PTY session stream. Resolves once the socket is open. */
|
|
431
|
+
attach(name, opts = {}) {
|
|
432
|
+
return this.openSession(this.sessionUrl(name, opts));
|
|
433
|
+
}
|
|
434
|
+
/** Open a host terminal's PTY session stream. */
|
|
435
|
+
attachTerminal(terminalId, opts = {}) {
|
|
436
|
+
return this.openSession(this.terminalSessionUrl(terminalId, opts));
|
|
437
|
+
}
|
|
438
|
+
async openSession(url) {
|
|
439
|
+
const headers = {};
|
|
440
|
+
if (this.token)
|
|
441
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
442
|
+
let ws;
|
|
443
|
+
if (this.wsFactory) {
|
|
444
|
+
ws = this.wsFactory(url, { headers });
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
ws = await defaultWebSocket(url, headers);
|
|
448
|
+
}
|
|
449
|
+
await waitOpen(ws);
|
|
450
|
+
return new Session(ws);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function encodePath(path) {
|
|
454
|
+
// Keep slashes (the daemon matches {path...}) but escape each segment.
|
|
455
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
456
|
+
}
|
|
457
|
+
async function errorMessage(resp) {
|
|
458
|
+
const body = (await resp.text()).trim();
|
|
459
|
+
try {
|
|
460
|
+
const data = JSON.parse(body);
|
|
461
|
+
if (data && typeof data.error === "string")
|
|
462
|
+
return data.error;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
/* not JSON */
|
|
466
|
+
}
|
|
467
|
+
return body || resp.statusText;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Open a WebSocket using the Node `ws` package when available (it can set the
|
|
471
|
+
* Authorization header), falling back to the global `WebSocket` (browser / Node
|
|
472
|
+
* 22+ — note that the global cannot set request headers, so token auth needs the
|
|
473
|
+
* `ws` package or operator/no-token access).
|
|
474
|
+
*/
|
|
475
|
+
async function defaultWebSocket(url, headers) {
|
|
476
|
+
try {
|
|
477
|
+
const mod = await import("ws");
|
|
478
|
+
const WS = mod.default ?? mod.WebSocket ?? mod;
|
|
479
|
+
return new WS(url, { headers });
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
const G = globalThis;
|
|
483
|
+
if (typeof G.WebSocket === "function")
|
|
484
|
+
return new G.WebSocket(url);
|
|
485
|
+
throw new DejimaError(0, "no WebSocket available; install 'ws' or pass options.wsFactory");
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function waitOpen(ws) {
|
|
489
|
+
return new Promise((resolve, reject) => {
|
|
490
|
+
const ok = () => resolve();
|
|
491
|
+
const bad = (e) => reject(new DejimaError(0, `websocket error: ${e?.message ?? e}`));
|
|
492
|
+
if (typeof ws.on === "function") {
|
|
493
|
+
ws.on("open", ok);
|
|
494
|
+
ws.on("error", bad);
|
|
495
|
+
}
|
|
496
|
+
else if (typeof ws.addEventListener === "function") {
|
|
497
|
+
ws.addEventListener("open", ok);
|
|
498
|
+
ws.addEventListener("error", bad);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
resolve(); // injected fake with no events; assume ready
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raised when the daemon returns a non-2xx response. The daemon's error body is
|
|
3
|
+
* JSON (`{"error": "..."}`); {@link message} is the extracted text when present,
|
|
4
|
+
* else the raw body.
|
|
5
|
+
*/
|
|
6
|
+
export declare class DejimaError extends Error {
|
|
7
|
+
readonly status: number;
|
|
8
|
+
/** Alias of {@link Error.message}, mirroring the Python client's `.message`. */
|
|
9
|
+
readonly description: string;
|
|
10
|
+
constructor(status: number, message: string);
|
|
11
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raised when the daemon returns a non-2xx response. The daemon's error body is
|
|
3
|
+
* JSON (`{"error": "..."}`); {@link message} is the extracted text when present,
|
|
4
|
+
* else the raw body.
|
|
5
|
+
*/
|
|
6
|
+
export class DejimaError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
/** Alias of {@link Error.message}, mirroring the Python client's `.message`. */
|
|
9
|
+
description;
|
|
10
|
+
constructor(status, message) {
|
|
11
|
+
super(`HTTP ${status}: ${message}`);
|
|
12
|
+
this.name = "DejimaError";
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.description = message;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dejima — TypeScript/JavaScript client for the Dejima API.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { Client } from "@dejima/sdk";
|
|
6
|
+
* const dj = new Client(); // reads DEJIMA_HOST / DEJIMA_TOKEN
|
|
7
|
+
* const isl = await dj.createIsland("git@github.com:you/foo.git", { agent: "claude-code" });
|
|
8
|
+
* console.log(await dj.listIslands());
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Alpha (0.x): fields may change until 1.0. The REST layer mirrors
|
|
12
|
+
* `openapi.yaml`; the only hand-written ergonomic piece is the PTY `Session`.
|
|
13
|
+
*/
|
|
14
|
+
export { Client } from "./client.js";
|
|
15
|
+
export type { ClientOptions } from "./client.js";
|
|
16
|
+
export { Session } from "./session.js";
|
|
17
|
+
export type { SessionEnvelope, WebSocketLike } from "./session.js";
|
|
18
|
+
export { DejimaError } from "./errors.js";
|
|
19
|
+
export declare const VERSION = "0.5.1";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dejima — TypeScript/JavaScript client for the Dejima API.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { Client } from "@dejima/sdk";
|
|
6
|
+
* const dj = new Client(); // reads DEJIMA_HOST / DEJIMA_TOKEN
|
|
7
|
+
* const isl = await dj.createIsland("git@github.com:you/foo.git", { agent: "claude-code" });
|
|
8
|
+
* console.log(await dj.listIslands());
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Alpha (0.x): fields may change until 1.0. The REST layer mirrors
|
|
12
|
+
* `openapi.yaml`; the only hand-written ergonomic piece is the PTY `Session`.
|
|
13
|
+
*/
|
|
14
|
+
export { Client } from "./client.js";
|
|
15
|
+
export { Session } from "./session.js";
|
|
16
|
+
export { DejimaError } from "./errors.js";
|
|
17
|
+
export const VERSION = "0.5.1";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { DejimaError } from "./errors.js";
|
|
2
|
+
/** One PTY session envelope on the wire (see openapi.yaml `SessionEnvelope`). */
|
|
3
|
+
export interface SessionEnvelope {
|
|
4
|
+
type: "hello" | "data" | "resize" | "error";
|
|
5
|
+
b64?: string;
|
|
6
|
+
rows?: number;
|
|
7
|
+
cols?: number;
|
|
8
|
+
attached?: Array<{
|
|
9
|
+
label: string;
|
|
10
|
+
joined_at: string;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A WebSocket as this SDK uses it. Both the browser `WebSocket`
|
|
15
|
+
* (`addEventListener`) and the Node `ws` package (`on`) satisfy this; tests
|
|
16
|
+
* inject a fake.
|
|
17
|
+
*/
|
|
18
|
+
export interface WebSocketLike {
|
|
19
|
+
send(data: string): void;
|
|
20
|
+
close(): void;
|
|
21
|
+
addEventListener?(type: string, listener: (ev: any) => void): void;
|
|
22
|
+
removeEventListener?(type: string, listener: (ev: any) => void): void;
|
|
23
|
+
on?(event: string, listener: (...args: any[]) => void): void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* An attached PTY session over the daemon's JSON-envelope WebSocket protocol.
|
|
27
|
+
*
|
|
28
|
+
* The wire is JSON envelopes with terminal bytes base64-encoded. {@link send}
|
|
29
|
+
* and {@link recv} deal in raw `Uint8Array`; the framing is handled for you.
|
|
30
|
+
* You can also drive it event-style with {@link onData}/{@link onClose}.
|
|
31
|
+
*/
|
|
32
|
+
export declare class Session {
|
|
33
|
+
private ws;
|
|
34
|
+
/** Buffered output chunks not yet pulled by recv(). */
|
|
35
|
+
private buffer;
|
|
36
|
+
/** Pending recv() resolvers waiting for the next chunk. */
|
|
37
|
+
private waiters;
|
|
38
|
+
private dataCbs;
|
|
39
|
+
private closeCbs;
|
|
40
|
+
private errorCbs;
|
|
41
|
+
private done;
|
|
42
|
+
/** Clients attached to this session at connect time (from the `hello`). */
|
|
43
|
+
attached: Array<{
|
|
44
|
+
label: string;
|
|
45
|
+
joined_at: string;
|
|
46
|
+
}>;
|
|
47
|
+
constructor(ws: WebSocketLike);
|
|
48
|
+
private wire;
|
|
49
|
+
private onMessage;
|
|
50
|
+
private finish;
|
|
51
|
+
/** Send raw bytes to the PTY (keystrokes). */
|
|
52
|
+
send(data: Uint8Array): void;
|
|
53
|
+
/** Send a UTF-8 string to the PTY. */
|
|
54
|
+
sendText(text: string): void;
|
|
55
|
+
/** Resize the PTY. */
|
|
56
|
+
resize(rows: number, cols: number): void;
|
|
57
|
+
/**
|
|
58
|
+
* Pull the next chunk of PTY output as raw bytes, or `null` once the session
|
|
59
|
+
* has closed. Mirrors the Python client's blocking `recv()`.
|
|
60
|
+
*/
|
|
61
|
+
recv(): Promise<Uint8Array | null>;
|
|
62
|
+
/** Register a callback for each output chunk. */
|
|
63
|
+
onData(cb: (bytes: Uint8Array) => void): void;
|
|
64
|
+
/** Register a callback for when the session closes. */
|
|
65
|
+
onClose(cb: () => void): void;
|
|
66
|
+
/** Register a callback for a server `error` envelope. */
|
|
67
|
+
onError(cb: (e: DejimaError) => void): void;
|
|
68
|
+
/** Close the underlying socket. */
|
|
69
|
+
close(): void;
|
|
70
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { DejimaError } from "./errors.js";
|
|
2
|
+
function base64Encode(bytes) {
|
|
3
|
+
// Cross-runtime base64 (Buffer in Node, btoa in the browser).
|
|
4
|
+
const g = globalThis;
|
|
5
|
+
if (typeof g.Buffer !== "undefined")
|
|
6
|
+
return g.Buffer.from(bytes).toString("base64");
|
|
7
|
+
let bin = "";
|
|
8
|
+
for (const b of bytes)
|
|
9
|
+
bin += String.fromCharCode(b);
|
|
10
|
+
return g.btoa(bin);
|
|
11
|
+
}
|
|
12
|
+
function base64Decode(s) {
|
|
13
|
+
const g = globalThis;
|
|
14
|
+
if (typeof g.Buffer !== "undefined")
|
|
15
|
+
return new Uint8Array(g.Buffer.from(s, "base64"));
|
|
16
|
+
const bin = g.atob(s);
|
|
17
|
+
const out = new Uint8Array(bin.length);
|
|
18
|
+
for (let i = 0; i < bin.length; i++)
|
|
19
|
+
out[i] = bin.charCodeAt(i);
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* An attached PTY session over the daemon's JSON-envelope WebSocket protocol.
|
|
24
|
+
*
|
|
25
|
+
* The wire is JSON envelopes with terminal bytes base64-encoded. {@link send}
|
|
26
|
+
* and {@link recv} deal in raw `Uint8Array`; the framing is handled for you.
|
|
27
|
+
* You can also drive it event-style with {@link onData}/{@link onClose}.
|
|
28
|
+
*/
|
|
29
|
+
export class Session {
|
|
30
|
+
ws;
|
|
31
|
+
/** Buffered output chunks not yet pulled by recv(). */
|
|
32
|
+
buffer = [];
|
|
33
|
+
/** Pending recv() resolvers waiting for the next chunk. */
|
|
34
|
+
waiters = [];
|
|
35
|
+
dataCbs = [];
|
|
36
|
+
closeCbs = [];
|
|
37
|
+
errorCbs = [];
|
|
38
|
+
done = false;
|
|
39
|
+
/** Clients attached to this session at connect time (from the `hello`). */
|
|
40
|
+
attached = [];
|
|
41
|
+
constructor(ws) {
|
|
42
|
+
this.ws = ws;
|
|
43
|
+
this.wire("message", (ev) => this.onMessage(typeof ev === "string" ? ev : ev?.data));
|
|
44
|
+
this.wire("close", () => this.finish());
|
|
45
|
+
this.wire("error", () => this.finish());
|
|
46
|
+
}
|
|
47
|
+
wire(event, fn) {
|
|
48
|
+
if (typeof this.ws.on === "function")
|
|
49
|
+
this.ws.on(event, fn);
|
|
50
|
+
else if (typeof this.ws.addEventListener === "function")
|
|
51
|
+
this.ws.addEventListener(event, fn);
|
|
52
|
+
}
|
|
53
|
+
onMessage(raw) {
|
|
54
|
+
if (raw == null)
|
|
55
|
+
return;
|
|
56
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf-8");
|
|
57
|
+
let env;
|
|
58
|
+
try {
|
|
59
|
+
env = JSON.parse(text);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
switch (env.type) {
|
|
65
|
+
case "hello":
|
|
66
|
+
this.attached = env.attached ?? [];
|
|
67
|
+
break;
|
|
68
|
+
case "data": {
|
|
69
|
+
const bytes = base64Decode(env.b64 ?? "");
|
|
70
|
+
for (const cb of this.dataCbs)
|
|
71
|
+
cb(bytes);
|
|
72
|
+
const w = this.waiters.shift();
|
|
73
|
+
if (w)
|
|
74
|
+
w(bytes);
|
|
75
|
+
else
|
|
76
|
+
this.buffer.push(bytes);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "error": {
|
|
80
|
+
// The daemon puts the plaintext error in `b64` for error envelopes.
|
|
81
|
+
const err = new DejimaError(0, env.b64 ?? "session error");
|
|
82
|
+
for (const cb of this.errorCbs)
|
|
83
|
+
cb(err);
|
|
84
|
+
this.finish();
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
finish() {
|
|
90
|
+
if (this.done)
|
|
91
|
+
return;
|
|
92
|
+
this.done = true;
|
|
93
|
+
for (const cb of this.closeCbs)
|
|
94
|
+
cb();
|
|
95
|
+
for (const w of this.waiters.splice(0))
|
|
96
|
+
w(null);
|
|
97
|
+
}
|
|
98
|
+
/** Send raw bytes to the PTY (keystrokes). */
|
|
99
|
+
send(data) {
|
|
100
|
+
this.ws.send(JSON.stringify({ type: "data", b64: base64Encode(data) }));
|
|
101
|
+
}
|
|
102
|
+
/** Send a UTF-8 string to the PTY. */
|
|
103
|
+
sendText(text) {
|
|
104
|
+
this.send(new TextEncoder().encode(text));
|
|
105
|
+
}
|
|
106
|
+
/** Resize the PTY. */
|
|
107
|
+
resize(rows, cols) {
|
|
108
|
+
this.ws.send(JSON.stringify({ type: "resize", rows, cols }));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Pull the next chunk of PTY output as raw bytes, or `null` once the session
|
|
112
|
+
* has closed. Mirrors the Python client's blocking `recv()`.
|
|
113
|
+
*/
|
|
114
|
+
recv() {
|
|
115
|
+
const queued = this.buffer.shift();
|
|
116
|
+
if (queued)
|
|
117
|
+
return Promise.resolve(queued);
|
|
118
|
+
if (this.done)
|
|
119
|
+
return Promise.resolve(null);
|
|
120
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
121
|
+
}
|
|
122
|
+
/** Register a callback for each output chunk. */
|
|
123
|
+
onData(cb) {
|
|
124
|
+
this.dataCbs.push(cb);
|
|
125
|
+
}
|
|
126
|
+
/** Register a callback for when the session closes. */
|
|
127
|
+
onClose(cb) {
|
|
128
|
+
this.closeCbs.push(cb);
|
|
129
|
+
}
|
|
130
|
+
/** Register a callback for a server `error` envelope. */
|
|
131
|
+
onError(cb) {
|
|
132
|
+
this.errorCbs.push(cb);
|
|
133
|
+
}
|
|
134
|
+
/** Close the underlying socket. */
|
|
135
|
+
close() {
|
|
136
|
+
try {
|
|
137
|
+
this.ws.close();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* best effort */
|
|
141
|
+
}
|
|
142
|
+
this.finish();
|
|
143
|
+
}
|
|
144
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dejima/sdk",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "TypeScript/JavaScript client for the Dejima API — run a fleet of AI coding agents on hardware you own.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
25
|
+
"test": "tsx --test test/*.test.ts"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"dejima",
|
|
29
|
+
"ai-agents",
|
|
30
|
+
"claude-code",
|
|
31
|
+
"codex",
|
|
32
|
+
"sandbox",
|
|
33
|
+
"self-hosted"
|
|
34
|
+
],
|
|
35
|
+
"homepage": "https://dejima.tech/",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/aoos/dejima.git",
|
|
39
|
+
"directory": "sdk/ts"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/aoos/dejima/issues"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^20.0.0",
|
|
46
|
+
"@types/ws": "^8.5.0",
|
|
47
|
+
"tsx": "^4.7.0",
|
|
48
|
+
"typescript": "^5.4.0"
|
|
49
|
+
},
|
|
50
|
+
"optionalDependencies": {
|
|
51
|
+
"ws": "^8.16.0"
|
|
52
|
+
}
|
|
53
|
+
}
|