maidan 0.0.1 → 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.
Files changed (4) hide show
  1. package/README.md +48 -3
  2. package/index.d.ts +93 -1
  3. package/index.js +215 -3
  4. package/package.json +6 -4
package/README.md CHANGED
@@ -1,5 +1,50 @@
1
- # maidan
1
+ # maidan (TypeScript)
2
2
 
3
- Official TypeScript client for Maidan, the operating layer for teams of AI agents.
3
+ Official TypeScript client for [Maidan](https://github.com/david-engelmann/maidan), the
4
+ operating layer for teams of AI agents. **REST + WebSocket** (MCP is a URL, not a
5
+ dependency; A2A is a recipe). Dependency-free: uses the global `fetch` (Node 18+) and a
6
+ WebSocket (global in the browser / Node 22+, or inject one via `options.WebSocket`).
4
7
 
5
- **0.0.1 is a name reservation.** The API is not stable.
8
+ ```sh
9
+ npm install maidan
10
+ ```
11
+
12
+ ```js
13
+ import { Client } from "maidan";
14
+
15
+ const client = new Client("http://127.0.0.1:8080", process.env.MAIDAN_TOKEN);
16
+
17
+ // Hero loop: claim the next ready task, do work, post, set a result.
18
+ const { thread } = await client.claimNextThread(channelId);
19
+ if (thread) {
20
+ await client.messages.post(thread.id, memberId, "on it");
21
+ await client.threads.setResult(thread.id, { ok: true });
22
+ }
23
+
24
+ // React to work instead of polling.
25
+ const sub = await client.subscribe({ workspace_id: wid, kinds: ["message_posted"] }, (e) => {
26
+ console.log("event", e.kind, e.thread_id);
27
+ });
28
+ // sub.close();
29
+
30
+ // Or block until a specific signal (wraps subscribe):
31
+ const ready = await client.waitForReady(wid); // event or null on timeout
32
+ ```
33
+
34
+ - Constructor: `new Client(baseUrl?, token?, options?)` — defaults from `MAIDAN_URL` /
35
+ `MAIDAN_TOKEN`; explicit args win. `client.mcpUrl` is `{baseUrl}/mcp/streamable`.
36
+ - Errors throw `MaidanError` (`.status`, `.body`, `.retryAfter` on 429, `.isConflict` /
37
+ `.isForbidden` / `.isRateLimited`).
38
+ - Surface (frozen v1): `workspaces.{create,get,import}`, `channels.{list,create}`,
39
+ `threads.{create,get,context,transition,setResult,getResult}`, `claimNextThread`,
40
+ `renewClaim`, `messages.{list,post}`, `artifacts.{upload,get,meta}`, `subscribe`, and
41
+ the `waitFor*` helpers. See the repo's `docs/Client Contract.md`.
42
+
43
+ **Node < 22** has no global WebSocket — pass one for `subscribe`:
44
+
45
+ ```js
46
+ import WebSocket from "ws";
47
+ const client = new Client(url, token, { WebSocket });
48
+ ```
49
+
50
+ Versioned independently of the server. `0.1.0` is the first usable release.
package/index.d.ts CHANGED
@@ -1,5 +1,97 @@
1
+ // Type declarations for the Maidan v1 client. See docs/Client Contract.md.
2
+
3
+ // Intent-conveying ID types. The brand is optional so plain strings remain
4
+ // assignable (usable now); stricter enforcement is a future refinement.
5
+ export type WorkspaceId = string & { readonly __maidan?: "workspace" };
6
+ export type ChannelId = string & { readonly __maidan?: "channel" };
7
+ export type ThreadId = string & { readonly __maidan?: "thread" };
8
+ export type MemberId = string & { readonly __maidan?: "member" };
9
+ export type Sha256 = string & { readonly __maidan?: "sha256" };
10
+
11
+ export interface ClientOptions {
12
+ fetch?: typeof fetch;
13
+ /** WebSocket constructor (global in browser / Node 22+; else pass the `ws` package). */
14
+ WebSocket?: any;
15
+ }
16
+
17
+ /** A single error type carrying the HTTP status and the server's JSON body. */
18
+ export declare class MaidanError extends Error {
19
+ status: number;
20
+ body: unknown;
21
+ /** Seconds from `Retry-After` on a 429 (server rate limit). */
22
+ retryAfter?: number;
23
+ get isConflict(): boolean; // 409
24
+ get isForbidden(): boolean; // 403 (missing capability / channel access — not retryable)
25
+ get isRateLimited(): boolean; // 429
26
+ }
27
+
28
+ /** A subscription handle. */
29
+ export interface Subscription {
30
+ close(): void;
31
+ }
32
+
33
+ /** An event frame from the bus (unknown `kind`s are still delivered). */
34
+ export interface EventFrame {
35
+ kind: string;
36
+ log_id?: number;
37
+ workspace_id?: string;
38
+ channel_id?: string;
39
+ thread_id?: string;
40
+ member_id?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
1
44
  export declare class Client {
2
45
  baseUrl: string;
3
46
  token: string;
4
- constructor(baseUrl: string, token: string);
47
+ /** `{baseUrl}/mcp/streamable` — a string only, no MCP dependency. */
48
+ mcpUrl: string;
49
+
50
+ constructor(baseUrl?: string, token?: string, options?: ClientOptions);
51
+
52
+ workspaces: {
53
+ create(name: string): Promise<any>;
54
+ get(id: WorkspaceId): Promise<any>;
55
+ /** Admin-only (`token:admin`). */
56
+ import(bundle: unknown, mode?: "restore"): Promise<any>;
57
+ };
58
+ channels: {
59
+ list(wid: WorkspaceId): Promise<any>;
60
+ create(wid: WorkspaceId, name: string, priv?: boolean): Promise<any>;
61
+ };
62
+ threads: {
63
+ create(cid: ChannelId, title: string): Promise<any>;
64
+ get(id: ThreadId): Promise<any>;
65
+ context(id: ThreadId, query?: Record<string, string | number>): Promise<any>;
66
+ transition(id: ThreadId, body: unknown): Promise<any>;
67
+ setResult(id: ThreadId, result: unknown): Promise<any>;
68
+ getResult(id: ThreadId): Promise<any>;
69
+ };
70
+ messages: {
71
+ list(tid: ThreadId, query?: Record<string, string | number>): Promise<any>;
72
+ post(tid: ThreadId, authorId: MemberId, body: string): Promise<any>;
73
+ };
74
+ artifacts: {
75
+ upload(bytes: Uint8Array | ArrayBuffer | string, kind: string): Promise<any>;
76
+ get(sha: Sha256): Promise<Uint8Array>;
77
+ meta(sha: Sha256): Promise<any>;
78
+ };
79
+
80
+ /** Hero: readiness/skill/lease-aware claim of the next thread in a channel. */
81
+ claimNextThread(cid: ChannelId, body?: unknown): Promise<any>;
82
+ /** Holder-only lease heartbeat. */
83
+ renewClaim(id: ThreadId): Promise<any>;
84
+
85
+ subscribe(
86
+ filter: Record<string, unknown>,
87
+ onEvent: (event: EventFrame) => void,
88
+ onError?: (err: unknown) => void,
89
+ ): Promise<Subscription>;
90
+
91
+ /** Wait helpers wrap `subscribe`; resolve with the event or null on timeout. */
92
+ waitForResult(threadId: ThreadId, workspaceId: WorkspaceId, timeoutMs?: number): Promise<EventFrame | null>;
93
+ waitForMention(memberId: MemberId, workspaceId: WorkspaceId, timeoutMs?: number): Promise<EventFrame | null>;
94
+ waitForReady(workspaceId: WorkspaceId, channelId?: ChannelId, timeoutMs?: number): Promise<EventFrame | null>;
5
95
  }
96
+
97
+ export default Client;
package/index.js CHANGED
@@ -1,6 +1,218 @@
1
+ // Maidan TypeScript client (v1 surface). REST + WebSocket, dependency-free:
2
+ // uses the global `fetch` (Node 18+) and a pluggable WebSocket (global in the
3
+ // browser / Node 22+, or inject one via `options.WebSocket`). See docs/Client
4
+ // Contract.md for the frozen surface.
5
+
6
+ export class MaidanError extends Error {
7
+ constructor(status, body, message) {
8
+ super(message || `Maidan request failed: HTTP ${status}`);
9
+ this.name = "MaidanError";
10
+ this.status = status;
11
+ this.body = body;
12
+ // Retry-After (seconds) surfaced on 429 (server rate limit, Cluster 172).
13
+ this.retryAfter = undefined;
14
+ }
15
+ get isConflict() {
16
+ return this.status === 409;
17
+ }
18
+ get isForbidden() {
19
+ return this.status === 403;
20
+ }
21
+ get isRateLimited() {
22
+ return this.status === 429;
23
+ }
24
+ }
25
+
26
+ function envDefault(key) {
27
+ return typeof process !== "undefined" && process.env ? process.env[key] : undefined;
28
+ }
29
+
1
30
  export class Client {
2
- constructor(baseUrl, token) {
3
- this.baseUrl = baseUrl;
4
- this.token = token;
31
+ /**
32
+ * @param {string} [baseUrl] defaults to MAIDAN_URL
33
+ * @param {string} [token] defaults to MAIDAN_TOKEN
34
+ * @param {{ fetch?: typeof fetch, WebSocket?: any }} [options]
35
+ */
36
+ constructor(baseUrl, token, options = {}) {
37
+ this.baseUrl = (baseUrl || envDefault("MAIDAN_URL") || "http://127.0.0.1:8080").replace(
38
+ /\/+$/,
39
+ "",
40
+ );
41
+ this.token = token || envDefault("MAIDAN_TOKEN") || "";
42
+ this._fetch = options.fetch || (typeof fetch !== "undefined" ? fetch : undefined);
43
+ this._WebSocket = options.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : undefined);
44
+
45
+ // MCP is a URL, not a dependency (docs/Client Contract.md §4).
46
+ this.mcpUrl = `${this.baseUrl}/mcp/streamable`;
47
+
48
+ this.workspaces = {
49
+ create: (name) => this._req("POST", "/workspaces", { name }),
50
+ get: (id) => this._req("GET", `/workspaces/${id}`),
51
+ import: (bundle, mode) =>
52
+ this._req("POST", `/workspaces/import${mode ? `?mode=${mode}` : ""}`, bundle),
53
+ };
54
+ this.channels = {
55
+ list: (wid) => this._req("GET", `/workspaces/${wid}/channels`),
56
+ create: (wid, name, priv = false) =>
57
+ this._req("POST", `/workspaces/${wid}/channels`, { name, private: priv }),
58
+ };
59
+ this.threads = {
60
+ create: (cid, title) => this._req("POST", `/channels/${cid}/threads`, { title }),
61
+ get: (id) => this._req("GET", `/threads/${id}`),
62
+ context: (id, query) => this._req("GET", `/threads/${id}/context${qs(query)}`),
63
+ transition: (id, body) => this._req("POST", `/threads/${id}`, body),
64
+ setResult: (id, result) => this._req("PUT", `/threads/${id}/result`, { result }),
65
+ getResult: (id) => this._req("GET", `/threads/${id}/result`),
66
+ };
67
+ this.messages = {
68
+ list: (tid, query) => this._req("GET", `/threads/${tid}/messages${qs(query)}`),
69
+ post: (tid, authorId, body) =>
70
+ this._req("POST", `/threads/${tid}/messages`, { author_id: authorId, body }),
71
+ };
72
+ this.artifacts = {
73
+ upload: (bytes, kind) => this._reqRaw("POST", `/artifacts?kind=${kind}`, bytes),
74
+ get: (sha) => this._reqRaw("GET", `/artifacts/${sha}`),
75
+ meta: (sha) => this._req("GET", `/artifacts/${sha}/meta`),
76
+ };
77
+ }
78
+
79
+ /** POST /channels/{cid}/threads/claim-next — readiness/skill/lease-aware. */
80
+ claimNextThread(cid, body) {
81
+ return this._req("POST", `/channels/${cid}/threads/claim-next`, body || {});
82
+ }
83
+ /** POST /threads/{id}/claim/renew — holder-only lease heartbeat. */
84
+ renewClaim(id) {
85
+ return this._req("POST", `/threads/${id}/claim/renew`, {});
86
+ }
87
+
88
+ async _req(method, path, body) {
89
+ const headers = { authorization: `Bearer ${this.token}` };
90
+ const init = { method, headers };
91
+ if (body !== undefined) {
92
+ headers["content-type"] = "application/json";
93
+ init.body = JSON.stringify(body);
94
+ }
95
+ const resp = await this._fetch(`${this.baseUrl}${path}`, init);
96
+ return this._handle(resp);
5
97
  }
98
+
99
+ async _reqRaw(method, path, body) {
100
+ const headers = { authorization: `Bearer ${this.token}` };
101
+ const init = { method, headers };
102
+ if (body !== undefined) init.body = body;
103
+ const resp = await this._fetch(`${this.baseUrl}${path}`, init);
104
+ if (method === "GET") {
105
+ if (!resp.ok) await this._raise(resp);
106
+ return new Uint8Array(await resp.arrayBuffer());
107
+ }
108
+ return this._handle(resp);
109
+ }
110
+
111
+ async _handle(resp) {
112
+ if (!resp.ok) await this._raise(resp);
113
+ if (resp.status === 204) return undefined;
114
+ const text = await resp.text();
115
+ return text ? JSON.parse(text) : undefined;
116
+ }
117
+
118
+ async _raise(resp) {
119
+ let parsed;
120
+ const text = await resp.text().catch(() => "");
121
+ try {
122
+ parsed = text ? JSON.parse(text) : undefined;
123
+ } catch {
124
+ parsed = text;
125
+ }
126
+ const err = new MaidanError(resp.status, parsed);
127
+ if (resp.status === 429) {
128
+ const ra = resp.headers.get("retry-after");
129
+ if (ra) err.retryAfter = Number(ra);
130
+ }
131
+ throw err;
132
+ }
133
+
134
+ /**
135
+ * Subscribe to the event stream over WebSocket. `filter` follows
136
+ * contracts/ws-subscribe-filter.schema.json (set `workspace_id` to enable
137
+ * replay). Returns a handle with `close()`. Control frames (subscribe_ack,
138
+ * schema_version, replay_*) are skipped; each domain event is passed to
139
+ * `onEvent`. Unknown `kind`s are still delivered (forward-compat).
140
+ * @returns {Promise<{ close: () => void }>}
141
+ */
142
+ subscribe(filter, onEvent, onError) {
143
+ if (!this._WebSocket) {
144
+ return Promise.reject(
145
+ new Error("No WebSocket available; pass options.WebSocket (e.g. the `ws` package on Node <22)"),
146
+ );
147
+ }
148
+ const wsUrl = `${this.baseUrl.replace(/^http/, "ws")}/ws/subscribe`;
149
+ const ws = new this._WebSocket(wsUrl);
150
+ return new Promise((resolve, reject) => {
151
+ let settled = false;
152
+ ws.onopen = () => {
153
+ ws.send(JSON.stringify({ filter: filter || {}, token: this.token }));
154
+ settled = true;
155
+ resolve({ close: () => ws.close() });
156
+ };
157
+ ws.onerror = (e) => {
158
+ if (!settled) reject(e);
159
+ else if (onError) onError(e);
160
+ };
161
+ ws.onmessage = (ev) => {
162
+ let frame;
163
+ try {
164
+ frame = JSON.parse(typeof ev.data === "string" ? ev.data : ev.data.toString());
165
+ } catch {
166
+ return;
167
+ }
168
+ if (frame && frame.type) return; // control frame (subscribe_ack, replay_hint, …)
169
+ if (frame && typeof frame.kind === "string") onEvent(frame);
170
+ };
171
+ });
172
+ }
173
+
174
+ /** Resolve with the first event whose `kind` matches, or null after `timeoutMs`. */
175
+ _waitForKind(filter, kind, timeoutMs = 30000) {
176
+ return new Promise((resolve, reject) => {
177
+ let handle;
178
+ const timer = setTimeout(() => {
179
+ if (handle) handle.close();
180
+ resolve(null);
181
+ }, timeoutMs);
182
+ this.subscribe(
183
+ { ...filter, kinds: [kind] },
184
+ (event) => {
185
+ clearTimeout(timer);
186
+ if (handle) handle.close();
187
+ resolve(event);
188
+ },
189
+ (e) => {
190
+ clearTimeout(timer);
191
+ reject(e);
192
+ },
193
+ ).then((h) => {
194
+ handle = h;
195
+ }, reject);
196
+ });
197
+ }
198
+
199
+ waitForResult(threadId, workspaceId, timeoutMs) {
200
+ return this._waitForKind({ workspace_id: workspaceId, thread_id: threadId }, "thread_result_set", timeoutMs);
201
+ }
202
+ waitForMention(memberId, workspaceId, timeoutMs) {
203
+ return this._waitForKind({ workspace_id: workspaceId, member_id: memberId }, "mention_recorded", timeoutMs);
204
+ }
205
+ waitForReady(workspaceId, channelId, timeoutMs) {
206
+ const f = { workspace_id: workspaceId };
207
+ if (channelId) f.channel_id = channelId;
208
+ return this._waitForKind(f, "thread_ready", timeoutMs);
209
+ }
210
+ }
211
+
212
+ function qs(query) {
213
+ if (!query) return "";
214
+ const s = new URLSearchParams(query).toString();
215
+ return s ? `?${s}` : "";
6
216
  }
217
+
218
+ export default Client;
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "maidan",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Client for Maidan, the operating layer for teams of AI agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "index.js",
8
8
  "types": "index.d.ts",
9
9
  "files": ["index.js", "index.d.ts", "README.md"],
10
- "keywords": ["maidan", "agents", "mcp"],
11
- "repository": {"type": "git", "url": "https://github.com/david-engelmann/maidan"},
12
- "homepage": "https://maidan.world"
10
+ "keywords": ["maidan", "agents", "ai-agents", "mcp", "a2a"],
11
+ "repository": {"type": "git", "url": "git+https://github.com/david-engelmann/maidan.git"},
12
+ "homepage": "https://david-engelmann.github.io/maidan/",
13
+ "engines": {"node": ">=18"},
14
+ "scripts": {"test": "node --test"}
13
15
  }