moshcode 0.59.0 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -7
- package/bin/moshcode.mjs +2 -2
- package/package.json +1 -1
- package/prd/0011-herd-agent-protocol.md +391 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +100 -6
- package/src/commands.mjs +84 -10
- package/src/cost.mjs +121 -2
- package/src/engines.mjs +32 -0
- package/src/herd-cli.mjs +812 -20
- package/src/herd-eval.mjs +301 -0
- package/src/herd-hooks.mjs +285 -0
- package/src/herd-remote.mjs +365 -0
- package/src/herd-serve.mjs +515 -0
- package/src/herd-state.mjs +167 -10
- package/src/herd-tasks.mjs +377 -0
- package/src/herd.mjs +89 -7
- package/src/templates.mjs +32 -5
- package/src/tools.mjs +43 -0
- package/src/tui.mjs +1 -1
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
// `moshcode herd serve` — the herd, over A2A v0.3.0 (PRD 0011 R9–R10).
|
|
2
|
+
//
|
|
3
|
+
// PRD 0009 took herdr's thesis — "the CLI and socket API are one surface agents
|
|
4
|
+
// drive" — and implemented it locally. A2A is that same thesis standardised
|
|
5
|
+
// across machines, and the mapping is not an integration to design so much as a
|
|
6
|
+
// translation table to write down:
|
|
7
|
+
//
|
|
8
|
+
// herd prompt → message/send blocked → input-required
|
|
9
|
+
// state / wait → tasks/get (poll) working → working
|
|
10
|
+
// kill → tasks/cancel done → completed
|
|
11
|
+
// ps / roster → agent-card discovery killed → canceled
|
|
12
|
+
//
|
|
13
|
+
// This is not a second API. It is the existing one answering a socket: every
|
|
14
|
+
// method here lands on the same herd verbs a person types, and mints the same
|
|
15
|
+
// ledger tasks (R5) that `herd tasks` reads back.
|
|
16
|
+
//
|
|
17
|
+
// SCOPE, deliberately small. v0.3.0, JSON-RPC, text parts. Streaming, push
|
|
18
|
+
// notifications and authenticated extended cards are declared *off* in the
|
|
19
|
+
// card's capability flags, which is what those flags are for. That is the same
|
|
20
|
+
// MVP surface the ADK itself ships, and a spec upgrade is its own PRD.
|
|
21
|
+
//
|
|
22
|
+
// SECURITY. `message/send` is keystrokes into a real pty, which is strictly
|
|
23
|
+
// more dangerous than a browser terminal — a terminal at least shows you what
|
|
24
|
+
// it is doing. So this reuses src/console.mjs's discipline wholesale: bind
|
|
25
|
+
// loopback by default, verify a moshcode token against app.moshcode.sh once,
|
|
26
|
+
// swap it for a short-lived HMAC credential, refuse unauthenticated requests
|
|
27
|
+
// before they reach anything, and warn loudly past loopback. There is no
|
|
28
|
+
// unauthenticated mode. Loopback included: every process on this box, and
|
|
29
|
+
// anything that can talk one of them into making a request, is on the other
|
|
30
|
+
// side of "loopback is safe".
|
|
31
|
+
import crypto from "node:crypto";
|
|
32
|
+
import http from "node:http";
|
|
33
|
+
|
|
34
|
+
import { loadCreds } from "./auth.mjs";
|
|
35
|
+
import { mintCookie, readCookie, verifyToken } from "./console.mjs";
|
|
36
|
+
import { capture, readManifest, sendKeys, sendPrompt } from "./herd.mjs";
|
|
37
|
+
import { roster } from "./herd-cli.mjs";
|
|
38
|
+
import { endTask, findTask, ledgerSessions, readTasks, screenDelta, startTask, TERMINAL_STATES } from "./herd-tasks.mjs";
|
|
39
|
+
import { moshcodeVersion } from "./ui.mjs";
|
|
40
|
+
|
|
41
|
+
export const A2A_PROTOCOL_VERSION = "0.3.0";
|
|
42
|
+
export const DEFAULT_SERVE_PORT = 7683;
|
|
43
|
+
|
|
44
|
+
/** The herd's states, as A2A says them. */
|
|
45
|
+
export const HERD_TO_A2A = {
|
|
46
|
+
working: "working",
|
|
47
|
+
blocked: "input-required",
|
|
48
|
+
done: "completed",
|
|
49
|
+
// A2A's vocabulary is smaller than ours, and this is where that costs
|
|
50
|
+
// something. `idle` and `unknown` are both "not asking for anything and not
|
|
51
|
+
// obviously finished", and the only two candidates are `working` and
|
|
52
|
+
// `input-required`. Rounding *up* to input-required would page a human for a
|
|
53
|
+
// session that has nothing to say, every time, so they round down and the
|
|
54
|
+
// honest state travels in the task's metadata.
|
|
55
|
+
idle: "working",
|
|
56
|
+
unknown: "working",
|
|
57
|
+
gone: "failed",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const a2aState = (state) => HERD_TO_A2A[state] || "working";
|
|
61
|
+
|
|
62
|
+
/* --------------------------------------------------------------- JSON-RPC */
|
|
63
|
+
|
|
64
|
+
export const RPC_ERRORS = {
|
|
65
|
+
parse: { code: -32700, message: "Invalid JSON payload" },
|
|
66
|
+
invalidRequest: { code: -32600, message: "Invalid JSON-RPC request" },
|
|
67
|
+
methodNotFound: { code: -32601, message: "Method not found" },
|
|
68
|
+
invalidParams: { code: -32602, message: "Invalid parameters" },
|
|
69
|
+
internal: { code: -32603, message: "Internal error" },
|
|
70
|
+
// A2A's own range.
|
|
71
|
+
taskNotFound: { code: -32001, message: "Task not found" },
|
|
72
|
+
taskNotCancelable: { code: -32002, message: "Task cannot be canceled" },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const rpcOk = (id, result) => ({ jsonrpc: "2.0", id: id ?? null, result });
|
|
76
|
+
const rpcErr = (id, error, data) => ({ jsonrpc: "2.0", id: id ?? null, error: { ...error, ...(data ? { data } : {}) } });
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------------- cards */
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A session is exposed unless it was launched autonomously.
|
|
82
|
+
*
|
|
83
|
+
* An engine running with its approvals bypassed, plus a network endpoint that
|
|
84
|
+
* accepts prompts, is the worst pairing on the menu: prompt injection reaching
|
|
85
|
+
* an agent that has already been told not to ask. So `--agent` sessions are off
|
|
86
|
+
* the protocol surface unless someone says otherwise out loud.
|
|
87
|
+
*/
|
|
88
|
+
export function exposable(session, { exposeAutonomous = false } = {}) {
|
|
89
|
+
if (session.kind === "remote") return false; // a remote is someone else's to serve
|
|
90
|
+
if (!exposeAutonomous && session.agent) return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The roster, filtered to what this server will admit exists. */
|
|
95
|
+
export function servedSessions({ exposeAutonomous = false, rows = roster() } = {}) {
|
|
96
|
+
const manifest = readManifest().sessions;
|
|
97
|
+
return rows
|
|
98
|
+
.map((row) => ({ ...row, agent: Boolean(manifest[row.name]?.agent) }))
|
|
99
|
+
.filter((row) => exposable(row, { exposeAutonomous }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const SECURITY = {
|
|
103
|
+
securitySchemes: { moshcode: { type: "http", scheme: "bearer", description: "a moshcode login token, or a credential from POST /auth" } },
|
|
104
|
+
security: [{ moshcode: [] }],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const CAPABILITIES = {
|
|
108
|
+
// Every one of these is false because it is false, not because it is
|
|
109
|
+
// unfinished — see the scope note at the top. A card that claimed streaming
|
|
110
|
+
// would be a client hanging on a stream that never opens.
|
|
111
|
+
streaming: false,
|
|
112
|
+
pushNotifications: false,
|
|
113
|
+
stateTransitionHistory: true,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** The card for one session. */
|
|
117
|
+
export function sessionCard(session, { base }) {
|
|
118
|
+
const url = `${String(base).replace(/\/+$/, "")}/${session.name}/`;
|
|
119
|
+
return {
|
|
120
|
+
protocolVersion: A2A_PROTOCOL_VERSION,
|
|
121
|
+
name: `${session.name} (${session.engine})`,
|
|
122
|
+
description: `A moshcode herd session running ${session.engine}${session.cwd ? ` in ${session.cwd}` : ""}.`,
|
|
123
|
+
url,
|
|
124
|
+
preferredTransport: "JSONRPC",
|
|
125
|
+
version: moshcodeVersion() || "0.0.0",
|
|
126
|
+
capabilities: CAPABILITIES,
|
|
127
|
+
defaultInputModes: ["text/plain"],
|
|
128
|
+
defaultOutputModes: ["text/plain"],
|
|
129
|
+
skills: [{
|
|
130
|
+
id: "prompt",
|
|
131
|
+
name: "prompt",
|
|
132
|
+
description: `Type a prompt into ${session.name} and collect what it produces.`,
|
|
133
|
+
tags: ["herd", "terminal", String(session.engine)],
|
|
134
|
+
examples: ["port the auth routes", "run the tests and summarise the failures"],
|
|
135
|
+
inputModes: ["text/plain"],
|
|
136
|
+
outputModes: ["text/plain"],
|
|
137
|
+
}],
|
|
138
|
+
supportsAuthenticatedExtendedCard: false,
|
|
139
|
+
...SECURITY,
|
|
140
|
+
metadata: {
|
|
141
|
+
"sh.moshcode.herd": {
|
|
142
|
+
session: session.name, engine: session.engine, state: session.state,
|
|
143
|
+
authority: session.authority, cwd: session.cwd,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The card for the herd itself: one skill per member.
|
|
151
|
+
*
|
|
152
|
+
* Both shapes are published rather than one, because they answer different
|
|
153
|
+
* questions. The herd card is discovery — "what is on this box" — and the
|
|
154
|
+
* per-session cards are what a client stores when it wants to talk to one
|
|
155
|
+
* member for a week. It also makes `herd remote add` of somebody else's single
|
|
156
|
+
* session symmetric with adding a whole herd.
|
|
157
|
+
*/
|
|
158
|
+
export function herdCard(sessions, { base }) {
|
|
159
|
+
return {
|
|
160
|
+
protocolVersion: A2A_PROTOCOL_VERSION,
|
|
161
|
+
name: "moshcode herd",
|
|
162
|
+
description: "Agent sessions running on this machine. Each member is addressable at /<name>/ with its own card.",
|
|
163
|
+
url: `${String(base).replace(/\/+$/, "")}/`,
|
|
164
|
+
preferredTransport: "JSONRPC",
|
|
165
|
+
version: moshcodeVersion() || "0.0.0",
|
|
166
|
+
capabilities: CAPABILITIES,
|
|
167
|
+
defaultInputModes: ["text/plain"],
|
|
168
|
+
defaultOutputModes: ["text/plain"],
|
|
169
|
+
skills: sessions.map((s) => ({
|
|
170
|
+
id: s.name,
|
|
171
|
+
name: s.name,
|
|
172
|
+
description: `${s.engine} — currently ${s.state}${s.cwd ? ` — ${s.cwd}` : ""}. Address it at /${s.name}/.`,
|
|
173
|
+
tags: ["herd", String(s.engine), String(s.state)],
|
|
174
|
+
})),
|
|
175
|
+
supportsAuthenticatedExtendedCard: false,
|
|
176
|
+
...SECURITY,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ------------------------------------------------------------------ tasks */
|
|
181
|
+
|
|
182
|
+
const iso = (ts) => new Date(Number(ts) || Date.now()).toISOString();
|
|
183
|
+
|
|
184
|
+
const textMessage = (text, { role = "agent", taskId, contextId } = {}) => ({
|
|
185
|
+
kind: "message",
|
|
186
|
+
role,
|
|
187
|
+
messageId: crypto.randomUUID(),
|
|
188
|
+
parts: [{ kind: "text", text: String(text ?? "") }],
|
|
189
|
+
...(taskId ? { taskId } : {}),
|
|
190
|
+
...(contextId ? { contextId } : {}),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A ledger task as an A2A Task.
|
|
195
|
+
*
|
|
196
|
+
* `live` is the session's state right now, which outranks the ledger's last
|
|
197
|
+
* transition for an open task: the ledger is written by whatever last polled,
|
|
198
|
+
* and a client asking tasks/get IS a poll.
|
|
199
|
+
*/
|
|
200
|
+
export function taskToA2a(task, { live = null } = {}) {
|
|
201
|
+
const herdState = task.status === "closed" ? (task.state || "done") : (live || task.state || "working");
|
|
202
|
+
// A FINISHED task is `completed`, whatever the session went back to being.
|
|
203
|
+
// The idle→working rounding above is about a *session* — "it is sitting
|
|
204
|
+
// there, it is not asking for anything" — and applying it to a task that has
|
|
205
|
+
// an outcome and an artifact would leave an A2A client polling a job that
|
|
206
|
+
// finished ten minutes ago. The one exception is a task that ended by
|
|
207
|
+
// stopping to ask, which is `input-required` in any vocabulary.
|
|
208
|
+
const state = task.status === "closed"
|
|
209
|
+
? (herdState === "blocked" ? "input-required" : "completed")
|
|
210
|
+
: a2aState(herdState);
|
|
211
|
+
return {
|
|
212
|
+
kind: "task",
|
|
213
|
+
id: task.id,
|
|
214
|
+
contextId: task.session,
|
|
215
|
+
status: {
|
|
216
|
+
state,
|
|
217
|
+
timestamp: iso(task.endedAt || task.transitions.at(-1)?.ts || task.submitted),
|
|
218
|
+
...(task.artifact ? { message: textMessage(task.artifact, { taskId: task.id, contextId: task.session }) } : {}),
|
|
219
|
+
},
|
|
220
|
+
history: [textMessage(task.text, { role: "user", taskId: task.id, contextId: task.session })],
|
|
221
|
+
artifacts: task.artifact
|
|
222
|
+
? [{
|
|
223
|
+
artifactId: `${task.id}-output`,
|
|
224
|
+
name: "screen",
|
|
225
|
+
description: "What appeared on the session's screen after the prompt was submitted.",
|
|
226
|
+
parts: [{ kind: "text", text: task.artifact }],
|
|
227
|
+
}]
|
|
228
|
+
: [],
|
|
229
|
+
metadata: {
|
|
230
|
+
// Where the vocabulary mismatch goes to stay honest.
|
|
231
|
+
"sh.moshcode.herd": {
|
|
232
|
+
session: task.session,
|
|
233
|
+
state: herdState,
|
|
234
|
+
status: task.status,
|
|
235
|
+
submitted: task.submitted,
|
|
236
|
+
endedAt: task.endedAt,
|
|
237
|
+
durationMs: task.durationMs,
|
|
238
|
+
truncated: Boolean(task.truncated),
|
|
239
|
+
transitions: task.transitions,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ------------------------------------------------------------------- auth */
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Who is allowed in.
|
|
249
|
+
*
|
|
250
|
+
* Two accepted credentials, in this order: an HMAC credential this process
|
|
251
|
+
* minted (cheap, local, expires), or a moshcode login token (verified against
|
|
252
|
+
* the app, then cached for the same window so a polling client does not become
|
|
253
|
+
* a load test on app.moshcode.sh).
|
|
254
|
+
*/
|
|
255
|
+
export function createAuth({
|
|
256
|
+
api = "https://app.moshcode.sh",
|
|
257
|
+
secret = crypto.randomBytes(32).toString("hex"),
|
|
258
|
+
verify = verifyToken,
|
|
259
|
+
ttlMs = 12 * 60 * 60 * 1000,
|
|
260
|
+
} = {}) {
|
|
261
|
+
const verified = new Map(); // sha256(token) → { user, until }
|
|
262
|
+
|
|
263
|
+
const hash = (token) => crypto.createHash("sha256").update(String(token)).digest("hex");
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
secret,
|
|
267
|
+
mint: (user) => mintCookie(secret, { user, ttlMs }),
|
|
268
|
+
async check(token, { now = Date.now() } = {}) {
|
|
269
|
+
if (!token) return null;
|
|
270
|
+
const local = readCookie(secret, token, now);
|
|
271
|
+
if (local) return local;
|
|
272
|
+
const key = hash(token);
|
|
273
|
+
const cached = verified.get(key);
|
|
274
|
+
if (cached && cached.until > now) return cached.user;
|
|
275
|
+
const user = await verify(api, token);
|
|
276
|
+
if (!user) { verified.delete(key); return null; }
|
|
277
|
+
verified.set(key, { user, until: now + Math.min(ttlMs, 15 * 60 * 1000) });
|
|
278
|
+
return user;
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The bearer token on a request, if there is one. */
|
|
284
|
+
export function bearer(req) {
|
|
285
|
+
const header = req?.headers?.authorization || "";
|
|
286
|
+
const match = /^Bearer\s+(.+)$/i.exec(String(header).trim());
|
|
287
|
+
return match ? match[1].trim() : "";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* ----------------------------------------------------------------- server */
|
|
291
|
+
|
|
292
|
+
const send = (res, status, body) => {
|
|
293
|
+
const text = JSON.stringify(body, null, 2);
|
|
294
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
295
|
+
res.end(text);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
function readBody(req, { limit = 1024 * 1024 } = {}) {
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
let size = 0;
|
|
301
|
+
const chunks = [];
|
|
302
|
+
req.on("data", (chunk) => {
|
|
303
|
+
size += chunk.length;
|
|
304
|
+
// A prompt is text. Anything past a megabyte is not a prompt, and reading
|
|
305
|
+
// it into memory to find that out is the whole attack.
|
|
306
|
+
if (size > limit) { resolve({ tooLarge: true, text: "" }); req.destroy(); return; }
|
|
307
|
+
chunks.push(chunk);
|
|
308
|
+
});
|
|
309
|
+
req.on("end", () => resolve({ tooLarge: false, text: Buffer.concat(chunks).toString("utf8") }));
|
|
310
|
+
req.on("error", () => resolve({ tooLarge: false, text: "" }));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The herd's A2A server.
|
|
316
|
+
*
|
|
317
|
+
* Routing, in full:
|
|
318
|
+
* GET /.well-known/agent-card.json the herd
|
|
319
|
+
* GET /<name>/.well-known/agent-card.json one member
|
|
320
|
+
* POST /auth token → short-lived credential
|
|
321
|
+
* POST /<name>/ message/send, tasks/get, tasks/cancel
|
|
322
|
+
* POST / tasks/get, tasks/cancel (ids are herd-wide)
|
|
323
|
+
*/
|
|
324
|
+
export function createHerdServer({
|
|
325
|
+
api = "https://app.moshcode.sh",
|
|
326
|
+
auth = createAuth({ api }),
|
|
327
|
+
exposeAutonomous = false,
|
|
328
|
+
base = `http://127.0.0.1:${DEFAULT_SERVE_PORT}`,
|
|
329
|
+
sessions = () => servedSessions({ exposeAutonomous }),
|
|
330
|
+
prompt = defaultPrompt,
|
|
331
|
+
interrupt = defaultInterrupt,
|
|
332
|
+
screen = capture,
|
|
333
|
+
now = () => Date.now(),
|
|
334
|
+
} = {}) {
|
|
335
|
+
const server = http.createServer(async (req, res) => {
|
|
336
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
337
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
338
|
+
|
|
339
|
+
// Auth first, before routing — a 404 that only unauthenticated callers can
|
|
340
|
+
// see is a way to ask which session names exist.
|
|
341
|
+
const user = await auth.check(bearer(req));
|
|
342
|
+
if (!user) {
|
|
343
|
+
res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Bearer realm="moshcode herd"' });
|
|
344
|
+
res.end(JSON.stringify({ error: "not authenticated", how: "Authorization: Bearer <moshcode token> — run `moshcode login` on the calling machine" }, null, 2));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (req.method === "POST" && segments.length === 1 && segments[0] === "auth") {
|
|
349
|
+
// The token that got here is already verified; this hands back something
|
|
350
|
+
// shorter-lived to use instead, so the real token stops travelling.
|
|
351
|
+
return send(res, 200, { credential: auth.mint(user), expiresIn: 12 * 60 * 60 });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const cardAt = segments.indexOf(".well-known");
|
|
355
|
+
if (req.method === "GET" && cardAt >= 0 && segments[cardAt + 1] === "agent-card.json") {
|
|
356
|
+
const rows = sessions();
|
|
357
|
+
if (cardAt === 0) return send(res, 200, herdCard(rows, { base }));
|
|
358
|
+
const found = rows.find((s) => s.name === segments[0]);
|
|
359
|
+
if (!found) return send(res, 404, { error: `no member named ${JSON.stringify(segments[0])}` });
|
|
360
|
+
return send(res, 200, sessionCard(found, { base }));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (req.method !== "POST") {
|
|
364
|
+
return send(res, 405, { error: "the A2A surface is POST for JSON-RPC and GET for agent cards" });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const body = await readBody(req);
|
|
368
|
+
if (body.tooLarge) return send(res, 413, rpcErr(null, RPC_ERRORS.invalidParams, "payload too large"));
|
|
369
|
+
let payload;
|
|
370
|
+
try { payload = JSON.parse(body.text); }
|
|
371
|
+
catch { return send(res, 400, rpcErr(null, RPC_ERRORS.parse)); }
|
|
372
|
+
if (!payload || payload.jsonrpc !== "2.0" || typeof payload.method !== "string") {
|
|
373
|
+
return send(res, 400, rpcErr(payload?.id, RPC_ERRORS.invalidRequest));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const member = segments.length && segments[0] !== "auth" ? segments[0] : null;
|
|
377
|
+
const answer = await handleRpc(payload, {
|
|
378
|
+
member, sessions, prompt, interrupt, screen, now,
|
|
379
|
+
});
|
|
380
|
+
// 200 even for an error: in JSON-RPC the transport succeeded and the error
|
|
381
|
+
// is the payload. A 4xx here would have clients retrying a method name.
|
|
382
|
+
return send(res, 200, answer);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
server.on("clientError", (_error, socket) => {
|
|
386
|
+
try { socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); } catch { /* already gone */ }
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return server;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Type a prompt into a live session.
|
|
394
|
+
*
|
|
395
|
+
* The one place the protocol becomes keystrokes. Everything above this is
|
|
396
|
+
* routing and everything below it is the engine's business. Injectable so the
|
|
397
|
+
* tests exercise the whole surface without a pty anywhere near them.
|
|
398
|
+
*/
|
|
399
|
+
function defaultPrompt(name, text) {
|
|
400
|
+
return sendPrompt(name, text);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Interrupt whatever a session is doing: Escape, then Ctrl-C.
|
|
405
|
+
*
|
|
406
|
+
* The same escalation `kill` uses, stopping one rung short of it on purpose. An
|
|
407
|
+
* A2A task is a unit of work inside a member, and a member is a long-lived
|
|
408
|
+
* thing somebody attached to five minutes ago — cancelling their task must not
|
|
409
|
+
* take their session with it. Ending a member is `moshcode kill`, which is a
|
|
410
|
+
* decision, not a protocol call.
|
|
411
|
+
*/
|
|
412
|
+
function defaultInterrupt(name) {
|
|
413
|
+
const first = sendKeys(name, ["Escape"]);
|
|
414
|
+
const second = sendKeys(name, ["C-c"]);
|
|
415
|
+
return { ok: Boolean(first.ok || second.ok) };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** The JSON-RPC methods, with no HTTP anywhere near them. */
|
|
419
|
+
export async function handleRpc(payload, { member, sessions, prompt, interrupt, screen, now = () => Date.now() }) {
|
|
420
|
+
const { id, method, params } = payload;
|
|
421
|
+
const rows = sessions();
|
|
422
|
+
|
|
423
|
+
if (method === "message/send") {
|
|
424
|
+
if (!member) return rpcErr(id, RPC_ERRORS.invalidParams, "address a member: POST /<name>/");
|
|
425
|
+
const session = rows.find((s) => s.name === member);
|
|
426
|
+
if (!session) return rpcErr(id, RPC_ERRORS.invalidParams, `no member named ${JSON.stringify(member)}`);
|
|
427
|
+
if (!session.alive || session.exited) return rpcErr(id, RPC_ERRORS.invalidParams, `${member} is not running`);
|
|
428
|
+
const text = messageText(params?.message);
|
|
429
|
+
if (!text) return rpcErr(id, RPC_ERRORS.invalidParams, "the message needs a text part");
|
|
430
|
+
|
|
431
|
+
const at = now();
|
|
432
|
+
const baseline = screen(member, { lines: 60 });
|
|
433
|
+
const taskId = startTask(member, text, { screen: baseline, now: at, state: session.state });
|
|
434
|
+
const sent = prompt(member, text);
|
|
435
|
+
if (!sent?.ok) {
|
|
436
|
+
endTask(member, taskId, { state: "done", artifact: `moshcode could not type into ${member}: ${sent?.error?.message || "unknown error"}`, ts: now() });
|
|
437
|
+
return rpcErr(id, RPC_ERRORS.internal, String(sent?.error?.message || "could not reach the session"));
|
|
438
|
+
}
|
|
439
|
+
// Returned before the engine has answered, on purpose: A2A's task model
|
|
440
|
+
// exists so a client polls rather than holding a socket open for the half
|
|
441
|
+
// hour an agent might take.
|
|
442
|
+
const task = readTasks(member).find((t) => t.id === taskId);
|
|
443
|
+
return rpcOk(id, taskToA2a(task || {
|
|
444
|
+
id: taskId, session: member, text, submitted: at, transitions: [], status: "open", state: "working", artifact: null,
|
|
445
|
+
}, { live: "working" }));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (method === "tasks/get") {
|
|
449
|
+
const found = locateTask(params?.id, { member, rows });
|
|
450
|
+
if (!found) return rpcErr(id, RPC_ERRORS.taskNotFound);
|
|
451
|
+
// A poll IS an observation, so it closes a task whose session has stopped.
|
|
452
|
+
// Without this the only thing that ever finishes a task is the watcher, and
|
|
453
|
+
// an A2A client — whose entire protocol is send-then-poll — would sit on
|
|
454
|
+
// `working` forever against a herd where nobody happened to run one.
|
|
455
|
+
const task = reconcileTask(found.task, found.session, { screen, now });
|
|
456
|
+
return rpcOk(id, taskToA2a(task, { live: found.session?.state || null }));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (method === "tasks/cancel") {
|
|
460
|
+
const found = locateTask(params?.id, { member, rows });
|
|
461
|
+
if (!found) return rpcErr(id, RPC_ERRORS.taskNotFound);
|
|
462
|
+
if (found.task.status === "closed") return rpcErr(id, RPC_ERRORS.taskNotCancelable, "that task has already finished");
|
|
463
|
+
const stopped = interrupt(found.task.session);
|
|
464
|
+
const artifact = screenDelta(found.task.baseline, screen(found.task.session, { lines: 200 }));
|
|
465
|
+
endTask(found.task.session, found.task.id, { state: "done", artifact, ts: now() });
|
|
466
|
+
const task = { ...found.task, status: "closed", state: "done", artifact, endedAt: now() };
|
|
467
|
+
const cancelled = taskToA2a(task);
|
|
468
|
+
cancelled.status.state = "canceled";
|
|
469
|
+
cancelled.metadata["sh.moshcode.herd"].interrupted = Boolean(stopped?.ok);
|
|
470
|
+
return rpcOk(id, cancelled);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return rpcErr(id, RPC_ERRORS.methodNotFound, method);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Close an open task whose session has already stopped, and hand back what the
|
|
478
|
+
* task now is. Leaves a task alone while its session is still working.
|
|
479
|
+
*/
|
|
480
|
+
function reconcileTask(task, session, { screen, now }) {
|
|
481
|
+
if (task.status === "closed" || !session) return task;
|
|
482
|
+
if (!TERMINAL_STATES.includes(session.state)) return task;
|
|
483
|
+
const artifact = screenDelta(task.baseline, screen(task.session, { lines: 400 }));
|
|
484
|
+
const at = now();
|
|
485
|
+
endTask(task.session, task.id, { state: session.state, artifact, ts: at });
|
|
486
|
+
return { ...task, status: "closed", state: session.state, artifact, endedAt: at, durationMs: task.submitted ? at - task.submitted : null };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Ids are herd-wide, so a task can be found with or without its member. */
|
|
490
|
+
function locateTask(taskId, { member, rows }) {
|
|
491
|
+
if (!taskId) return null;
|
|
492
|
+
const search = member ? [member] : ledgerSessions();
|
|
493
|
+
const task = findTask(String(taskId), { sessions: search });
|
|
494
|
+
if (!task) return null;
|
|
495
|
+
// A task in a member this server does not expose does not exist here either.
|
|
496
|
+
const session = rows.find((s) => s.name === task.session);
|
|
497
|
+
if (!session) return null;
|
|
498
|
+
return { task, session };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The text of an A2A message. Text parts only — see the scope note. */
|
|
502
|
+
export function messageText(message) {
|
|
503
|
+
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
504
|
+
return parts
|
|
505
|
+
.filter((p) => p?.kind === "text" || typeof p?.text === "string")
|
|
506
|
+
.map((p) => String(p.text ?? ""))
|
|
507
|
+
.join("\n")
|
|
508
|
+
.trim();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The credentials `herd serve` needs to verify anyone at all. */
|
|
512
|
+
export function serveCredentials() {
|
|
513
|
+
const creds = loadCreds();
|
|
514
|
+
return { api: creds?.api || "https://app.moshcode.sh", token: creds?.token || "" };
|
|
515
|
+
}
|