gsjos 0.0.0-stage → 0.6.2
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 +179 -2
- package/bin/gsjos.mjs +16 -0
- package/package.json +41 -4
- package/src/cli.mjs +321 -0
- package/src/client.mjs +171 -0
- package/src/daemon.mjs +333 -0
- package/src/mcp.mjs +140 -0
- package/src/runtime.mjs +214 -0
- package/src/verbs.mjs +814 -0
package/src/client.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The API-client core. Both faces (CLI, MCP) go through this and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// Auth is a workspace-scoped API key — NOT a user password and not a
|
|
4
|
+
// service-role key. The server binds each key to a workspace agent identity, so
|
|
5
|
+
// every request runs under the same row-level security as a human member.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { verbByName } from "./verbs.mjs";
|
|
11
|
+
|
|
12
|
+
const CONFIG_PATH = join(homedir(), ".gsjos", "config.json");
|
|
13
|
+
|
|
14
|
+
export function loadConfig() {
|
|
15
|
+
const fromFile = existsSync(CONFIG_PATH)
|
|
16
|
+
? JSON.parse(readFileSync(CONFIG_PATH, "utf8"))
|
|
17
|
+
: {};
|
|
18
|
+
return {
|
|
19
|
+
// Environment wins over the file: a droplet gets its credentials injected,
|
|
20
|
+
// a laptop gets them from `gsjos login`.
|
|
21
|
+
apiUrl: process.env.GSJOS_API_URL || fromFile.apiUrl || "http://localhost:3000",
|
|
22
|
+
apiKey: process.env.GSJOS_API_KEY || fromFile.apiKey || "",
|
|
23
|
+
workspace: process.env.GSJOS_WORKSPACE || fromFile.workspace || "",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveConfig(config) {
|
|
28
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
29
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
30
|
+
return CONFIG_PATH;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const configPath = () => CONFIG_PATH;
|
|
34
|
+
|
|
35
|
+
export class GsjosError extends Error {
|
|
36
|
+
constructor(message, status, code = null, details = null) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.status = status;
|
|
39
|
+
/** The API's stable machine-readable code — `insufficient_scope`,
|
|
40
|
+
* `rate_limited`, `invalid_value`. Branch on this, never on the message. */
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.details = details;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One HTTP call against /api/v1. Throws GsjosError with the server's own
|
|
47
|
+
* message — the API returns useful errors (e.g. which status values are legal),
|
|
48
|
+
* and swallowing them would defeat the guardrail design.
|
|
49
|
+
*
|
|
50
|
+
* Pass `{ raw: true }` to get the whole envelope including `next_cursor`,
|
|
51
|
+
* rather than just `data`.
|
|
52
|
+
*
|
|
53
|
+
* `idempotencyKey` is forwarded as the `Idempotency-Key` header. The API
|
|
54
|
+
* replays the original response for 24h rather than re-running the handler, so
|
|
55
|
+
* a script that creates a TAM can be re-run without creating a second one. */
|
|
56
|
+
export async function call(config, { method, path, query, body }, { raw = false, idempotencyKey } = {}) {
|
|
57
|
+
if (!config.apiKey) {
|
|
58
|
+
throw new GsjosError("No API key. Run `gsjos login`, or set GSJOS_API_KEY.", 401);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const url = new URL(`/api/v1${path}`, config.apiUrl);
|
|
62
|
+
if (config.workspace) url.searchParams.set("workspace", config.workspace);
|
|
63
|
+
for (const [k, v] of Object.entries(query ?? {})) {
|
|
64
|
+
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Strip undefined so a PATCH never sends a field the caller did not name —
|
|
68
|
+
// the API distinguishes "absent" from "set to null".
|
|
69
|
+
const payload = body
|
|
70
|
+
? Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined))
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
73
|
+
let res;
|
|
74
|
+
try {
|
|
75
|
+
res = await fetch(url, {
|
|
76
|
+
method,
|
|
77
|
+
headers: {
|
|
78
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
79
|
+
...(payload ? { "Content-Type": "application/json" } : {}),
|
|
80
|
+
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
|
81
|
+
},
|
|
82
|
+
body: payload ? JSON.stringify(payload) : undefined,
|
|
83
|
+
});
|
|
84
|
+
} catch (e) {
|
|
85
|
+
throw new GsjosError(`Could not reach ${config.apiUrl}: ${e.message}`, 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
let json = null;
|
|
90
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON body */ }
|
|
91
|
+
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
// The error shape is `{ error: { code, message, details? } }`. Older
|
|
94
|
+
// deployments returned a bare string, so both are accepted — a CLI that
|
|
95
|
+
// printed "[object Object]" against a server one version behind would be a
|
|
96
|
+
// uniquely unhelpful failure.
|
|
97
|
+
const err = json?.error;
|
|
98
|
+
const message = typeof err === "string" ? err : err?.message ?? text ?? `HTTP ${res.status}`;
|
|
99
|
+
const code = typeof err === "object" && err ? err.code ?? null : null;
|
|
100
|
+
const details = typeof err === "object" && err ? err.details ?? null : null;
|
|
101
|
+
|
|
102
|
+
if (res.status === 429) {
|
|
103
|
+
const wait = res.headers.get("retry-after");
|
|
104
|
+
throw new GsjosError(wait ? `${message} (retry after ${wait}s)` : message, 429, code ?? "rate_limited", details);
|
|
105
|
+
}
|
|
106
|
+
throw new GsjosError(message, res.status, code, details);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (raw) return json ?? {};
|
|
110
|
+
|
|
111
|
+
// An envelope always carries `data`, and `data: null` is a real answer — an
|
|
112
|
+
// empty queue. Falling back to `?? json` there returns the envelope itself,
|
|
113
|
+
// which is truthy, so "nothing to do" reads as work and the caller acts on a
|
|
114
|
+
// command that does not exist. Check for the key, not the value.
|
|
115
|
+
if (json && typeof json === "object" && "data" in json) return json.data;
|
|
116
|
+
return json;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Walk every page of a list endpoint and return the rows as one array.
|
|
120
|
+
*
|
|
121
|
+
* Keyset pagination means this is exact: no row is skipped or repeated even
|
|
122
|
+
* while the workspace is being written to underneath the walk. That is the
|
|
123
|
+
* whole reason `--all` can be offered at all. */
|
|
124
|
+
export async function walkAll(config, verb, args) {
|
|
125
|
+
// `--limit` becomes the page size rather than a cap: with `--all` the caller
|
|
126
|
+
// has already said they want everything, so the only thing left for limit to
|
|
127
|
+
// mean is how much to fetch at a time. Default to the server's maximum —
|
|
128
|
+
// fewer, larger pages is strictly cheaper for a full walk.
|
|
129
|
+
const pageSize = Number(args.limit) > 0 ? Number(args.limit) : 200;
|
|
130
|
+
const rows = [];
|
|
131
|
+
let cursor;
|
|
132
|
+
let seen = 0;
|
|
133
|
+
|
|
134
|
+
for (;;) {
|
|
135
|
+
const page = await call(config, verb.request({ ...args, cursor, limit: pageSize }), { raw: true });
|
|
136
|
+
const batch = Array.isArray(page?.data) ? page.data : [];
|
|
137
|
+
rows.push(...batch);
|
|
138
|
+
|
|
139
|
+
const next = page?.next_cursor ?? null;
|
|
140
|
+
if (!next) return rows;
|
|
141
|
+
// A cursor that does not advance would loop forever and quietly eat the
|
|
142
|
+
// machine's memory. It can only mean a server bug, so say so rather than
|
|
143
|
+
// hanging.
|
|
144
|
+
if (next === cursor) {
|
|
145
|
+
throw new GsjosError(`${verb.name}: the server returned the same cursor twice — stopping after ${rows.length} rows.`, 500);
|
|
146
|
+
}
|
|
147
|
+
cursor = next;
|
|
148
|
+
|
|
149
|
+
if (++seen > 10000) {
|
|
150
|
+
throw new GsjosError(`${verb.name}: stopped after 10000 pages. Narrow the query.`, 500);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Run a verb by name. The single execution path both faces share. */
|
|
156
|
+
export async function runVerb(config, name, args, { idempotencyKey } = {}) {
|
|
157
|
+
const verb = verbByName(name);
|
|
158
|
+
if (!verb) throw new GsjosError(`Unknown verb '${name}'.`, 400);
|
|
159
|
+
|
|
160
|
+
for (const [argName, spec] of Object.entries(verb.args)) {
|
|
161
|
+
if (spec.required && (args[argName] === undefined || args[argName] === "")) {
|
|
162
|
+
throw new GsjosError(`${name}: '${argName}' is required (${spec.desc}).`, 400);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (verb.paginated && (args.all === true || args.all === "true")) {
|
|
167
|
+
const { all: _all, ...rest } = args;
|
|
168
|
+
return walkAll(config, verb, rest);
|
|
169
|
+
}
|
|
170
|
+
return call(config, verb.request(args), { idempotencyKey });
|
|
171
|
+
}
|
package/src/daemon.mjs
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// The daemon face — what makes agents able to run longer than an HTTP request.
|
|
2
|
+
//
|
|
3
|
+
// The OS is deployed serverless. A route handler cannot hold a WebSocket open,
|
|
4
|
+
// cannot outlive its timeout, and has no fixed egress IP to allowlist at a
|
|
5
|
+
// tenant's firewall. So the OS never dials into the box. It enqueues work, and
|
|
6
|
+
// this process — running ON the box, next to the runtime — claims it over a
|
|
7
|
+
// connection it opens outbound.
|
|
8
|
+
//
|
|
9
|
+
// The consequences are the point: a tenant box needs no public hostname, no
|
|
10
|
+
// certificate, no exposed admin port, and works behind NAT. The only credential
|
|
11
|
+
// it holds is a workspace API key, which is RLS-scoped to that one workspace.
|
|
12
|
+
|
|
13
|
+
import { hostname } from "node:os";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { call, GsjosError } from "./client.mjs";
|
|
16
|
+
import { executeCommand } from "./runtime.mjs";
|
|
17
|
+
|
|
18
|
+
const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || "ws://127.0.0.1:18789";
|
|
19
|
+
const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || "";
|
|
20
|
+
|
|
21
|
+
/** Deltas are batched before being written. Realtime fans out every UPDATE, so
|
|
22
|
+
* a write per token would be pure amplification for no perceptible gain. */
|
|
23
|
+
const FLUSH_MS = 400;
|
|
24
|
+
/** Quiet gap after the last token that counts as end-of-turn, when the gateway
|
|
25
|
+
* sends no explicit completion marker. Reported so it can be retired. */
|
|
26
|
+
const QUIET_GAP_MS = 5_000;
|
|
27
|
+
const FIRST_TOKEN_TIMEOUT_MS = 180_000;
|
|
28
|
+
|
|
29
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
|
31
|
+
|
|
32
|
+
function requireWebSocket() {
|
|
33
|
+
if (typeof WebSocket === "undefined") {
|
|
34
|
+
throw new Error("This Node build has no global WebSocket. Node 22+ is required for `gsjos daemon`.");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function connectParams(token) {
|
|
39
|
+
// `webchat` mode demands a browser Origin allowlist; a backend process must
|
|
40
|
+
// identify as `backend` or the gateway refuses the handshake.
|
|
41
|
+
return {
|
|
42
|
+
minProtocol: 3,
|
|
43
|
+
maxProtocol: 4,
|
|
44
|
+
client: { id: "gateway-client", version: "0.2.0", platform: "node", mode: "backend" },
|
|
45
|
+
// `id` is validated against a fixed allowlist by the gateway; "gateway-client"
|
|
46
|
+
// is the accepted value for a backend client. A custom string is rejected with
|
|
47
|
+
// "invalid connect params: at /client/id".
|
|
48
|
+
role: "operator",
|
|
49
|
+
scopes: ["operator.read", "operator.write"],
|
|
50
|
+
auth: { token },
|
|
51
|
+
userAgent: "gsjos-daemon/0.2.0",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Run one turn against the local gateway, streaming deltas to `onDelta`.
|
|
56
|
+
* Resolves with { output, endReason }; rejects on protocol or auth failure. */
|
|
57
|
+
function runTurn({ sessionKey, message, onDelta }) {
|
|
58
|
+
requireWebSocket();
|
|
59
|
+
if (!GATEWAY_TOKEN) throw new Error("OPENCLAW_GATEWAY_TOKEN is not set");
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const ws = new WebSocket(GATEWAY_URL);
|
|
63
|
+
let output = "";
|
|
64
|
+
let sawReply = false;
|
|
65
|
+
let settled = false;
|
|
66
|
+
let connectId = "", sendId = "", createId = "";
|
|
67
|
+
let triedCreate = false;
|
|
68
|
+
let quietTimer = null;
|
|
69
|
+
|
|
70
|
+
const finish = (fn, arg) => {
|
|
71
|
+
if (settled) return;
|
|
72
|
+
settled = true;
|
|
73
|
+
if (quietTimer) clearTimeout(quietTimer);
|
|
74
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
75
|
+
fn(arg);
|
|
76
|
+
};
|
|
77
|
+
const done = (endReason) => finish(resolve, { output, endReason });
|
|
78
|
+
const fail = (msg) => finish(reject, new Error(msg));
|
|
79
|
+
|
|
80
|
+
const arm = () => {
|
|
81
|
+
if (quietTimer) clearTimeout(quietTimer);
|
|
82
|
+
quietTimer = setTimeout(
|
|
83
|
+
() => (sawReply ? done("quiet-gap") : fail("the agent produced no output in time")),
|
|
84
|
+
sawReply ? QUIET_GAP_MS : FIRST_TOKEN_TIMEOUT_MS,
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const rpc = (method, params) => {
|
|
89
|
+
const id = crypto.randomUUID();
|
|
90
|
+
ws.send(JSON.stringify({ type: "req", id, method, params }));
|
|
91
|
+
return id;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
ws.onopen = () => { connectId = rpc("connect", connectParams(GATEWAY_TOKEN)); };
|
|
95
|
+
|
|
96
|
+
ws.onmessage = (raw) => {
|
|
97
|
+
let f;
|
|
98
|
+
try { f = JSON.parse(String(raw.data)); } catch { return; }
|
|
99
|
+
|
|
100
|
+
if (f.type === "res" && f.id === connectId) {
|
|
101
|
+
if (f.ok === false) {
|
|
102
|
+
// The gateway distinguishes "wrong token" from "not ready yet" and
|
|
103
|
+
// says which. A restart answers UNAVAILABLE/retryable with a suggested
|
|
104
|
+
// delay; treating that as a permanent failure burns a run for a
|
|
105
|
+
// condition that clears in seconds.
|
|
106
|
+
const e = f.error ?? {};
|
|
107
|
+
const err = new Error("gateway rejected the token: " + JSON.stringify(e));
|
|
108
|
+
err.retryable = e.retryable === true || e.code === "UNAVAILABLE";
|
|
109
|
+
err.retryAfterMs = Number(e.retryAfterMs) || 1000;
|
|
110
|
+
return finish(reject, err);
|
|
111
|
+
}
|
|
112
|
+
sendId = rpc("sessions.send", { key: sessionKey, message });
|
|
113
|
+
return arm();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (f.type === "res" && f.id === sendId) {
|
|
117
|
+
if (f.ok === false) {
|
|
118
|
+
const detail = JSON.stringify(f.error ?? {});
|
|
119
|
+
if (!triedCreate && /session not found/i.test(detail)) {
|
|
120
|
+
triedCreate = true;
|
|
121
|
+
createId = rpc("sessions.create", { key: sessionKey });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
return fail("sessions.send failed: " + detail);
|
|
125
|
+
}
|
|
126
|
+
// A success response after tokens have streamed marks completion;
|
|
127
|
+
// before any token it is only an ack.
|
|
128
|
+
if (sawReply) done("send-response");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (f.type === "res" && f.id === createId) {
|
|
133
|
+
if (f.ok === false) return fail("sessions.create failed: " + JSON.stringify(f.error ?? {}));
|
|
134
|
+
sendId = rpc("sessions.send", { key: sessionKey, message });
|
|
135
|
+
return arm();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (f.type !== "event" || !f.payload) return;
|
|
139
|
+
const p = f.payload;
|
|
140
|
+
|
|
141
|
+
if (f.event === "chat") {
|
|
142
|
+
if (typeof p.deltaText === "string" && p.deltaText.length > 0) {
|
|
143
|
+
sawReply = true;
|
|
144
|
+
output = p.replace === true ? p.deltaText : output + p.deltaText;
|
|
145
|
+
onDelta(output);
|
|
146
|
+
}
|
|
147
|
+
if (p.final === true || p.done === true || p.complete === true) return done("final-event");
|
|
148
|
+
return arm();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if ((f.event === "session" || f.event === "sessions.state") && sawReply) {
|
|
152
|
+
if (p.state === "idle" || p.status === "idle" || p.busy === false) done("idle-event");
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
ws.onerror = () => fail("gateway unreachable at " + GATEWAY_URL);
|
|
157
|
+
ws.onclose = () => (sawReply ? done("socket-close") : fail("gateway closed before replying"));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Claim one management command and execute it. Returns true if work was done.
|
|
162
|
+
*
|
|
163
|
+
* Commands are drained BEFORE runs each tick, deliberately: a sync that
|
|
164
|
+
* installs or repairs an agent should land before the next turn is handed to
|
|
165
|
+
* that agent, or the turn runs against a definition the OS already replaced. */
|
|
166
|
+
async function processCommand(config, instanceId, { reap = false } = {}) {
|
|
167
|
+
const cmd = await call(config, {
|
|
168
|
+
method: "POST", path: "/runtime/claim",
|
|
169
|
+
body: { claimedBy: instanceId, reap },
|
|
170
|
+
});
|
|
171
|
+
if (!cmd) return false;
|
|
172
|
+
|
|
173
|
+
let result, failure = null;
|
|
174
|
+
try {
|
|
175
|
+
result = await executeCommand(config, cmd);
|
|
176
|
+
if (result?.error) failure = result.error;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
failure = e.message;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await call(config, {
|
|
182
|
+
method: "PATCH", path: "/runtime/" + cmd.id,
|
|
183
|
+
body: { status: failure ? "failed" : "done", result: result ?? null, error: failure },
|
|
184
|
+
}).catch(() => { /* the reaper will requeue it */ });
|
|
185
|
+
|
|
186
|
+
log(failure
|
|
187
|
+
? "command " + String(cmd.id).slice(0, 8) + " failed: " + failure
|
|
188
|
+
: "command " + String(cmd.id).slice(0, 8) + " done");
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Claim one run and execute it. Returns true if work was done. */
|
|
193
|
+
async function processOne(config, instanceId, { reap = false } = {}) {
|
|
194
|
+
const claimed = await call(config, {
|
|
195
|
+
method: "POST", path: "/runs/claim",
|
|
196
|
+
body: { claimedBy: instanceId, runtime: "openclaw", reap },
|
|
197
|
+
});
|
|
198
|
+
if (!claimed?.run) return false;
|
|
199
|
+
|
|
200
|
+
const { run, agent } = claimed;
|
|
201
|
+
const label = agent?.slug ?? String(run.agent_id ?? "").slice(0, 8) ?? "agent";
|
|
202
|
+
log("run " + run.id.slice(0, 8) + " -> " + label);
|
|
203
|
+
|
|
204
|
+
const sessionKey = sessionKeyForRun(run, agent, config.workspace);
|
|
205
|
+
|
|
206
|
+
let lastFlush = 0;
|
|
207
|
+
let lastWritten = "";
|
|
208
|
+
const flush = async (text, force) => {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
if (!force && (now - lastFlush < FLUSH_MS || text === lastWritten)) return;
|
|
211
|
+
lastFlush = now;
|
|
212
|
+
lastWritten = text;
|
|
213
|
+
try {
|
|
214
|
+
await call(config, { method: "PATCH", path: "/runs/" + run.id, body: { output: text } });
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// A dropped progress write must not kill the turn; the final write retries.
|
|
217
|
+
log(" ! progress write failed: " + e.message);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
// A gateway that is mid-restart refuses with retryable=true and a suggested
|
|
223
|
+
// delay. Wait it out rather than failing the run — bounded, so a gateway
|
|
224
|
+
// that never comes up still surfaces as a failure instead of hanging.
|
|
225
|
+
let attempt = 0;
|
|
226
|
+
const turn = async () => {
|
|
227
|
+
for (;;) {
|
|
228
|
+
try {
|
|
229
|
+
return await runTurn({ sessionKey, message: run.input, onDelta: (t) => { void flush(t, false); } });
|
|
230
|
+
} catch (e) {
|
|
231
|
+
if (!e.retryable || ++attempt > 6) throw e;
|
|
232
|
+
log(" gateway not ready, retrying in " + e.retryAfterMs + "ms (" + attempt + "/6)");
|
|
233
|
+
await sleep(e.retryAfterMs);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const { output, endReason } = await turn();
|
|
238
|
+
await call(config, {
|
|
239
|
+
method: "PATCH", path: "/runs/" + run.id,
|
|
240
|
+
body: { status: "done", output, endReason },
|
|
241
|
+
});
|
|
242
|
+
log("done " + run.id.slice(0, 8) + " (" + output.length + " chars, ended by " + endReason + ")");
|
|
243
|
+
} catch (e) {
|
|
244
|
+
await call(config, {
|
|
245
|
+
method: "PATCH", path: "/runs/" + run.id,
|
|
246
|
+
body: { status: "failed", error: e.message },
|
|
247
|
+
}).catch(() => { /* the reaper will requeue it */ });
|
|
248
|
+
log("failed " + run.id.slice(0, 8) + ": " + e.message);
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The loop. Polls for queued work; backs off when the queue is empty so an
|
|
254
|
+
* idle tenant costs almost nothing, and drains greedily when it is not. */
|
|
255
|
+
/** The package's own version, read rather than restated.
|
|
256
|
+
*
|
|
257
|
+
* The worker is started as `npx -y gsjos daemon`, with no version
|
|
258
|
+
* pinned — so "which build is actually running on the box" is a real question
|
|
259
|
+
* with no other way to answer it. Printing it on startup makes `docker logs`
|
|
260
|
+
* the answer. Falls back rather than throwing: an unreadable package.json is
|
|
261
|
+
* not a reason to refuse to run. */
|
|
262
|
+
const VERSION = (() => {
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
265
|
+
} catch {
|
|
266
|
+
return "0.0.0";
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
269
|
+
|
|
270
|
+
/** Which conversation this run belongs to.
|
|
271
|
+
*
|
|
272
|
+
* A chat run arrives with a key already set by the OS, because that is a human
|
|
273
|
+
* conversation and continuity is the whole point of it. Everything else — cron,
|
|
274
|
+
* webhook, event, manual — gets a session of its own, keyed by the run.
|
|
275
|
+
*
|
|
276
|
+
* Falling back to one session per TRIGGER TYPE looked harmless and was not: a
|
|
277
|
+
* daily cron appends to the same session every morning, so after a month the
|
|
278
|
+
* agent starts its run carrying twenty-nine previous ones. Context grows
|
|
279
|
+
* without bound, every turn gets slower and dearer, and what the agent does
|
|
280
|
+
* today quietly depends on what it saw last week. Observed directly — five
|
|
281
|
+
* unrelated probe runs, and the agent listed all five back in order.
|
|
282
|
+
*
|
|
283
|
+
* The OS is the memory. An agent reads the person, the thread and the pipeline
|
|
284
|
+
* through gsjos at run time rather than remembering them, so a fresh session
|
|
285
|
+
* loses nothing that matters and costs less. */
|
|
286
|
+
export function sessionKeyForRun(run, agent, workspace) {
|
|
287
|
+
if (run.session_key) return run.session_key;
|
|
288
|
+
return "agent:" + (agent?.slug ?? "main") + ":" + (workspace || "ws")
|
|
289
|
+
+ "-" + run.trigger_type + "-" + run.id;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function startDaemon(config, { pollMs = 2000, maxIdleMs = 15000 } = {}) {
|
|
293
|
+
const instanceId = hostname() + "-" + process.pid;
|
|
294
|
+
let stopping = false;
|
|
295
|
+
let idleFor = pollMs;
|
|
296
|
+
|
|
297
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
298
|
+
process.on(sig, () => {
|
|
299
|
+
if (stopping) process.exit(1);
|
|
300
|
+
stopping = true;
|
|
301
|
+
log(sig + " - finishing the current run, then stopping.");
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
log("gsjos daemon v" + VERSION + " " + instanceId);
|
|
306
|
+
log(" os: " + config.apiUrl + " (workspace: " + (config.workspace || "default") + ")");
|
|
307
|
+
log(" gateway: " + GATEWAY_URL);
|
|
308
|
+
if (!GATEWAY_TOKEN) log(" ! OPENCLAW_GATEWAY_TOKEN is not set - turns will fail");
|
|
309
|
+
|
|
310
|
+
let first = true;
|
|
311
|
+
while (!stopping) {
|
|
312
|
+
try {
|
|
313
|
+
// The first claim also reaps runs abandoned by a previous instance.
|
|
314
|
+
// Management first, then a turn. Both share one poll so an idle host
|
|
315
|
+
// makes one request per tick rather than two.
|
|
316
|
+
const didCommand = await processCommand(config, instanceId, { reap: first });
|
|
317
|
+
const didRun = didCommand ? false : await processOne(config, instanceId, { reap: first });
|
|
318
|
+
const worked = didCommand || didRun;
|
|
319
|
+
first = false;
|
|
320
|
+
idleFor = worked ? pollMs : Math.min(idleFor * 1.5, maxIdleMs);
|
|
321
|
+
if (!worked) await sleep(idleFor);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
const status = e instanceof GsjosError ? e.status : 0;
|
|
324
|
+
if (status === 401 || status === 403) {
|
|
325
|
+
log("fatal: " + e.message);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
log("poll error: " + e.message);
|
|
329
|
+
await sleep(Math.min(idleFor * 2, maxIdleMs));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
log("stopped.");
|
|
333
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// MCP server (stdio) — the second face over the same verbs.
|
|
2
|
+
//
|
|
3
|
+
// Newline-delimited JSON-RPC 2.0 on stdin/stdout, implemented directly so the
|
|
4
|
+
// package stays dependency-free: an agent droplet can `npx gsjos mcp`
|
|
5
|
+
// with nothing else installed, and there is no SDK version to drift.
|
|
6
|
+
//
|
|
7
|
+
// IMPORTANT: stdout is the protocol channel. Anything written to it that is not
|
|
8
|
+
// a JSON-RPC message corrupts the stream, so all diagnostics go to stderr.
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { VERBS, toolName, verbByToolName } from "./verbs.mjs";
|
|
12
|
+
import { runVerb, GsjosError } from "./client.mjs";
|
|
13
|
+
|
|
14
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
15
|
+
|
|
16
|
+
/** Read the real version rather than restating it here, where it went stale at
|
|
17
|
+
* 0.2.0 while the package shipped 0.4.x. A client that reports the server
|
|
18
|
+
* version in a bug report should not be reporting fiction. Falls back rather
|
|
19
|
+
* than throwing: an unreadable package.json is not a reason to refuse to run. */
|
|
20
|
+
const version = (() => {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
23
|
+
} catch {
|
|
24
|
+
return "0.0.0";
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
const SERVER_INFO = { name: "gsjos", version };
|
|
29
|
+
|
|
30
|
+
/** JSON Schema for a verb's arguments, from the same arg specs the CLI parses. */
|
|
31
|
+
function inputSchema(verb) {
|
|
32
|
+
const properties = {};
|
|
33
|
+
const required = [];
|
|
34
|
+
for (const [name, spec] of Object.entries(verb.args)) {
|
|
35
|
+
properties[name] = spec.type === "list"
|
|
36
|
+
? { type: "array", items: { type: "string" }, description: spec.desc }
|
|
37
|
+
// `objects` is how a caller sends MANY rows in one call. An agent asked to
|
|
38
|
+
// save fifty companies should make one tool call, not fifty — fifty is
|
|
39
|
+
// slow, and it is fifty chances to lose track half way.
|
|
40
|
+
: spec.type === "objects"
|
|
41
|
+
? { type: "array", items: { type: "object", additionalProperties: true }, description: spec.desc }
|
|
42
|
+
: { type: spec.type === "number" ? "number" : "string", description: spec.desc };
|
|
43
|
+
if (spec.required) required.push(name);
|
|
44
|
+
}
|
|
45
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const toolList = () => VERBS.map((verb) => ({
|
|
49
|
+
name: toolName(verb),
|
|
50
|
+
description: verb.summary,
|
|
51
|
+
inputSchema: inputSchema(verb),
|
|
52
|
+
annotations: { readOnlyHint: verb.kind === "read" },
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
export function startMcpServer(config) {
|
|
56
|
+
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|
|
57
|
+
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
|
58
|
+
const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
59
|
+
|
|
60
|
+
async function handle(msg) {
|
|
61
|
+
const { id, method, params } = msg;
|
|
62
|
+
// Notifications carry no id and must not be answered.
|
|
63
|
+
const isNotification = id === undefined || id === null;
|
|
64
|
+
|
|
65
|
+
switch (method) {
|
|
66
|
+
case "initialize":
|
|
67
|
+
return reply(id, {
|
|
68
|
+
// Echo the client's protocol version when we can speak it, so a
|
|
69
|
+
// client pinned to an older revision still negotiates cleanly.
|
|
70
|
+
protocolVersion: params?.protocolVersion ?? PROTOCOL_VERSION,
|
|
71
|
+
capabilities: { tools: { listChanged: false } },
|
|
72
|
+
serverInfo: SERVER_INFO,
|
|
73
|
+
instructions:
|
|
74
|
+
"Operate a gsjmedia OS workspace. Call `schema` first — it returns the status vocabularies, "
|
|
75
|
+
+ "legal pipeline stages, and writable fields for this workspace. Promotion from person to lead "
|
|
76
|
+
+ "is automatic: log an inbound interaction rather than creating a lead. Illegal status values "
|
|
77
|
+
+ "are rejected by the database, and the error names the legal set.",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
case "notifications/initialized":
|
|
81
|
+
case "notifications/cancelled":
|
|
82
|
+
return;
|
|
83
|
+
|
|
84
|
+
case "ping":
|
|
85
|
+
return reply(id, {});
|
|
86
|
+
|
|
87
|
+
case "tools/list":
|
|
88
|
+
return reply(id, { tools: toolList() });
|
|
89
|
+
|
|
90
|
+
case "tools/call": {
|
|
91
|
+
const verb = verbByToolName(params?.name);
|
|
92
|
+
if (!verb) return fail(id, -32602, `Unknown tool '${params?.name}'`);
|
|
93
|
+
try {
|
|
94
|
+
const data = await runVerb(config, verb.name, params?.arguments ?? {});
|
|
95
|
+
return reply(id, {
|
|
96
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
97
|
+
structuredContent: data && typeof data === "object" && !Array.isArray(data) ? data : undefined,
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// A failed tool call is a RESULT with isError, not a protocol error —
|
|
101
|
+
// the agent needs to read the message and correct itself. The API's
|
|
102
|
+
// errors name the legal values, which is exactly what it needs.
|
|
103
|
+
return reply(id, {
|
|
104
|
+
content: [{ type: "text", text: e instanceof GsjosError ? e.message : String(e?.message ?? e) }],
|
|
105
|
+
isError: true,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
default:
|
|
111
|
+
if (isNotification) return;
|
|
112
|
+
return fail(id, -32601, `Method '${method}' is not supported`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let buffer = "";
|
|
117
|
+
process.stdin.setEncoding("utf8");
|
|
118
|
+
process.stdin.on("data", (chunk) => {
|
|
119
|
+
buffer += chunk;
|
|
120
|
+
let newline;
|
|
121
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
122
|
+
const line = buffer.slice(0, newline).trim();
|
|
123
|
+
buffer = buffer.slice(newline + 1);
|
|
124
|
+
if (!line) continue;
|
|
125
|
+
let msg;
|
|
126
|
+
try {
|
|
127
|
+
msg = JSON.parse(line);
|
|
128
|
+
} catch {
|
|
129
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
handle(msg).catch((e) => {
|
|
133
|
+
if (msg.id !== undefined && msg.id !== null) fail(msg.id, -32603, String(e?.message ?? e));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
process.stdin.on("end", () => process.exit(0));
|
|
139
|
+
process.stderr.write(`gsjos mcp — ${VERBS.length} tools, workspace '${config.workspace || "(default)"}' at ${config.apiUrl}\n`);
|
|
140
|
+
}
|