@inhuman.tools/harness-contract 0.0.0-stage → 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 (53) hide show
  1. package/LICENSE.md +7 -0
  2. package/dist/contract.d.ts +1910 -0
  3. package/dist/contract.d.ts.map +1 -0
  4. package/dist/contract.js +353 -0
  5. package/dist/contract.js.map +1 -0
  6. package/dist/index.d.ts +7 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +7 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/jsonrpc.d.ts +18 -0
  11. package/dist/jsonrpc.d.ts.map +1 -0
  12. package/dist/jsonrpc.js +34 -0
  13. package/dist/jsonrpc.js.map +1 -0
  14. package/dist/schemas/authorization.d.ts +1721 -0
  15. package/dist/schemas/authorization.d.ts.map +1 -0
  16. package/dist/schemas/authorization.js +289 -0
  17. package/dist/schemas/authorization.js.map +1 -0
  18. package/dist/schemas/connection.d.ts +133 -0
  19. package/dist/schemas/connection.d.ts.map +1 -0
  20. package/dist/schemas/connection.js +55 -0
  21. package/dist/schemas/connection.js.map +1 -0
  22. package/dist/schemas/rpc.d.ts +86 -0
  23. package/dist/schemas/rpc.d.ts.map +1 -0
  24. package/dist/schemas/rpc.js +66 -0
  25. package/dist/schemas/rpc.js.map +1 -0
  26. package/dist/schemas/shapes.d.ts +514 -0
  27. package/dist/schemas/shapes.d.ts.map +1 -0
  28. package/dist/schemas/shapes.js +342 -0
  29. package/dist/schemas/shapes.js.map +1 -0
  30. package/dist/spec/cli.d.ts +3 -0
  31. package/dist/spec/cli.d.ts.map +1 -0
  32. package/dist/spec/cli.js +40 -0
  33. package/dist/spec/cli.js.map +1 -0
  34. package/dist/spec/generate.d.ts +21 -0
  35. package/dist/spec/generate.d.ts.map +1 -0
  36. package/dist/spec/generate.js +100 -0
  37. package/dist/spec/generate.js.map +1 -0
  38. package/dist/spec/readme.d.ts +4 -0
  39. package/dist/spec/readme.d.ts.map +1 -0
  40. package/dist/spec/readme.js +124 -0
  41. package/dist/spec/readme.js.map +1 -0
  42. package/package.json +57 -3
  43. package/src/contract.ts +418 -0
  44. package/src/index.ts +6 -0
  45. package/src/jsonrpc.ts +42 -0
  46. package/src/schemas/authorization.ts +312 -0
  47. package/src/schemas/connection.ts +68 -0
  48. package/src/schemas/rpc.ts +88 -0
  49. package/src/schemas/shapes.ts +379 -0
  50. package/src/spec/cli.ts +45 -0
  51. package/src/spec/generate.ts +115 -0
  52. package/src/spec/readme.ts +126 -0
  53. package/README.md +0 -3
@@ -0,0 +1,379 @@
1
+ import { z } from "zod";
2
+
3
+ import { uiFormSchema } from "./rpc.js";
4
+
5
+ /**
6
+ * Mirrors `REASONING_EFFORTS` in `@inhuman.tools/plugin-sdk`. Inlined so this
7
+ * package stays dependency-light; harness has a test that the two lists match.
8
+ */
9
+ export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
10
+
11
+ const nonempty = z.string().trim().min(1);
12
+
13
+ /** Access to one already-running MCP server, by the server's name. */
14
+ export const botMcpGrantSchema = z.strictObject({
15
+ server: nonempty,
16
+ tools: z.array(nonempty).optional(),
17
+ account: nonempty.optional(),
18
+ session: nonempty.optional(),
19
+ });
20
+
21
+ export type BotMcpGrant = z.infer<typeof botMcpGrantSchema>;
22
+
23
+ export const kvScopeSchema = z.enum(["session", "project", "kernel"]).default("session");
24
+
25
+ export const kvSetInput = z.object({
26
+ key: z.string().trim().min(1, "key is required"),
27
+ value: z.string(),
28
+ scope: kvScopeSchema,
29
+ /**
30
+ * Session `model` / `connection` also become the last-choice default
31
+ * (project + kernel). `/model` and `/connect` send "1". Scheme #t does
32
+ * not survive the action JSON walk. A bare session write — subagent,
33
+ * `--model`, `--connection` — does not.
34
+ */
35
+ remember: z
36
+ .union([z.boolean(), z.literal("1")])
37
+ .optional()
38
+ .transform((value) => value === true || value === "1"),
39
+ });
40
+ export const kvClearInput = z.object({
41
+ key: z.string().trim().min(1, "key is required"),
42
+ scope: kvScopeSchema,
43
+ });
44
+ export const kvOutput = z.object({ ok: z.literal(true) });
45
+
46
+ /**
47
+ * A plugin command's call into that plugin's own state. `args` is a JSON
48
+ * object, since action params cross as strings. `field` names the argument
49
+ * the composer text (`input`) fills; an empty `input` then refuses.
50
+ */
51
+ export const pluginMethodInput = z.object({
52
+ plugin: z.string().trim().min(1, "plugin is required"),
53
+ method: z.string().trim().min(1, "method is required"),
54
+ scope: z.enum(["session", "project", "global"]).default("session"),
55
+ args: z.string().optional(),
56
+ field: z.string().trim().optional(),
57
+ input: z.string().optional(),
58
+ });
59
+ /** A failure is data, so a command body that reads state still renders; the TUI shows `error` for a fired action. */
60
+ export const pluginMethodOutput = z.union([
61
+ z.object({ ok: z.literal(true), value: z.unknown() }),
62
+ z.object({ ok: z.literal(false), error: z.string() }),
63
+ ]);
64
+
65
+ export const policySetInput = z.object({
66
+ policy: z.enum(["manual", "auto", "yolo"]),
67
+ });
68
+ export const policySetOutput = z.object({ ok: z.literal(true) });
69
+
70
+ export const termContinueSetInput = z.object({
71
+ key: z.string().trim().min(1, "key is required"),
72
+ sessionId: z.string().trim().min(1, "sessionId is required"),
73
+ });
74
+ export const termContinueClearInput = z.object({
75
+ key: z.string().trim().min(1, "key is required"),
76
+ });
77
+ export const termContinueOutput = z.object({ ok: z.literal(true) });
78
+
79
+ /** One picture on a `turn`. Bytes are standard base64. The daemon stores them beside the session. */
80
+ export const turnVisualInput = z.object({
81
+ mime: z.string().trim().min(1),
82
+ bytesBase64: z.string().trim().min(1).max(7_000_000),
83
+ sourcePath: z.string().optional(),
84
+ });
85
+
86
+ export const turnInput = z.object({
87
+ prompt: z.string(),
88
+ kind: z.enum(["steer", "queue", "continue"]).optional(),
89
+ visuals: z.array(turnVisualInput).max(8).optional(),
90
+ });
91
+ export const turnOutput = z.object({ ok: z.literal(true) });
92
+
93
+ export const turnVisualReadInput = z.object({
94
+ id: z.string().trim().min(1),
95
+ });
96
+ export const turnVisualReadOutput = z.object({
97
+ id: z.string(),
98
+ mime: z.string(),
99
+ bytesBase64: z.string(),
100
+ width: z.number(),
101
+ height: z.number(),
102
+ resizedFrom: z.object({ width: z.number(), height: z.number() }).nullable(),
103
+ sourcePath: z.string().nullable(),
104
+ });
105
+
106
+ /** A blob the session's document references by content hash (`StoredBlob.sha256`). */
107
+ export const blobGetInput = z.object({
108
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
109
+ });
110
+ /** `text` for `text/*` media types, `bytesBase64` for everything else; exactly one is non-null. */
111
+ export const blobGetOutput = z.object({
112
+ sha256: z.string(),
113
+ bytes: z.number().int().min(0),
114
+ mime: z.string(),
115
+ text: z.string().nullable(),
116
+ bytesBase64: z.string().nullable(),
117
+ });
118
+
119
+ /** `dryRun` counts what would move and writes nothing. */
120
+ export const blobVacuumInput = z.object({
121
+ dryRun: z.boolean().optional(),
122
+ });
123
+ /** What moved (or, on a dry run, would): entities by seat, the UTF-8 bytes taken out of the document, and the stored image's size either side. */
124
+ export const blobVacuumOutput = z.object({
125
+ dryRun: z.boolean(),
126
+ fileMutates: z.number().int().min(0),
127
+ promptMessages: z.number().int().min(0),
128
+ promptTexts: z.number().int().min(0),
129
+ bytesMoved: z.number().int().min(0),
130
+ imageBytesBefore: z.number().int().min(0),
131
+ imageBytesAfter: z.number().int().min(0),
132
+ });
133
+
134
+ export const connectAdoptInput = z.object({
135
+ driver: z.union([
136
+ z.string().trim().min(1),
137
+ z.object({ name: z.string().trim().min(1) }).transform((row) => row.name),
138
+ ]),
139
+ name: z.string().optional(),
140
+ settings: z.record(z.string(), z.unknown()).optional(),
141
+ secrets: z.record(z.string(), z.string()).optional(),
142
+ });
143
+ export const connectAdoptOutput = z.union([z.object({ id: z.string() }), z.object({ ok: z.literal(false) })]);
144
+ export const sessionConnectAdoptOutput = z.union([connectAdoptOutput, uiFormSchema]);
145
+ export const connectDropInput = z.object({
146
+ id: z.string().trim().min(1, "id is required"),
147
+ confirm: z.boolean().optional(),
148
+ });
149
+ export const connectDropOutput = z.object({ ok: z.boolean() });
150
+
151
+ export const mcpServerAdoptInput = z.object({
152
+ name: z.string().optional(),
153
+ input: z.string().optional(),
154
+ settings: z.record(z.string(), z.unknown()).optional(),
155
+ secrets: z.record(z.string(), z.string()).optional(),
156
+ });
157
+ export const mcpServerAdoptOutput = connectAdoptOutput;
158
+
159
+ export const mcpServerSearchInput = z.object({
160
+ query: z.string().trim().min(1, "query is required"),
161
+ limit: z.number().int().min(1).max(20).optional(),
162
+ });
163
+ export const mcpServerSearchHit = z.object({
164
+ name: z.string(),
165
+ title: z.string(),
166
+ description: z.string(),
167
+ install: z.string(),
168
+ });
169
+ export const mcpServerSearchOutput = z.object({
170
+ servers: z.array(mcpServerSearchHit),
171
+ });
172
+
173
+ export const pluginSetInput = z
174
+ .object({
175
+ name: z.string().trim().min(1, "name is required"),
176
+ enabled: z.boolean().optional(),
177
+ foreground: z.boolean().optional(),
178
+ })
179
+ .refine((row) => row.enabled !== undefined || row.foreground !== undefined, {
180
+ message: "enabled or foreground is required",
181
+ });
182
+ export const pluginSetOutput = z.object({ ok: z.literal(true) });
183
+
184
+ export const pluginInstallInput = z.object({
185
+ spec: z.string().trim().min(1, "spec is required"),
186
+ });
187
+ export const pluginInstallOutput = z.object({
188
+ name: z.string(),
189
+ npmName: z.string(),
190
+ });
191
+ export const pluginRemoveInput = z.object({
192
+ name: z.string().trim().min(1, "name is required"),
193
+ });
194
+ export const pluginRemoveOutput = z.object({ ok: z.literal(true) });
195
+
196
+ /** Rename or merge settings of a connection that already exists. The id stays. */
197
+ export const connectUpdateInput = z
198
+ .object({
199
+ id: z.string().trim().min(1, "id is required"),
200
+ name: z.string().trim().min(1).optional(),
201
+ settings: z.record(z.string(), z.unknown()).optional(),
202
+ })
203
+ .refine((row) => row.name !== undefined || row.settings !== undefined, {
204
+ message: "name or settings is required",
205
+ });
206
+ export const connectUpdateOutput = z.object({ ok: z.literal(true) });
207
+
208
+ export const configSetInput = z.object({
209
+ compact: z.string().optional(),
210
+ compactKeepTurns: z.number().int().positive().optional(),
211
+ compactTriggers: z.object({ hard: z.string().optional(), soft: z.string().optional() }).optional(),
212
+ contextGlowRemaining: z.string().optional(),
213
+ autoModeClassifier: z.string().optional(),
214
+ defaultPermissionMode: z.enum(["manual", "auto", "yolo"]).optional(),
215
+ approvalAskThrottleMs: z.number().finite().nonnegative().optional(),
216
+ shellTailBytes: z.number().finite().nonnegative().optional(),
217
+ superKey: z.enum(["ctrl", "cmd"]).optional(),
218
+ hideFloorTools: z.boolean().optional(),
219
+ statusline: z.string().optional(),
220
+ statuslineRefresh: z.string().optional(),
221
+ speculativePolicyProcessingTools: z.number().finite().nonnegative().optional(),
222
+ textOnlyContinue: z.boolean().optional(),
223
+ /** The user layer of the authorization table (validated by the daemon). `null` clears it. */
224
+ authorization: z.record(z.string(), z.unknown()).nullable().optional(),
225
+ });
226
+ export const configSetOutput = z.object({ ok: z.literal(true) });
227
+
228
+ const skillScope = z.enum(["project", "home"]);
229
+ export const skillListInput = z.object({
230
+ project: z.string().optional(),
231
+ });
232
+ export const skillRowSchema = z.object({
233
+ name: z.string(),
234
+ description: z.string(),
235
+ location: z.string(),
236
+ invoke: z.string(),
237
+ scope: skillScope,
238
+ });
239
+ export const skillListOutput = z.object({ skills: z.array(skillRowSchema) });
240
+ export const skillWriteInput = z.object({
241
+ scope: skillScope,
242
+ project: z.string().optional(),
243
+ name: z.string().trim().min(1, "name is required"),
244
+ description: z.string().default(""),
245
+ body: z.string().default(""),
246
+ });
247
+ export const skillWriteOutput = skillRowSchema;
248
+ export const skillRemoveInput = z.object({
249
+ scope: skillScope,
250
+ project: z.string().optional(),
251
+ name: z.string().trim().min(1, "name is required"),
252
+ });
253
+ export const skillRemoveOutput = z.object({ ok: z.literal(true) });
254
+ export const skillReadInput = skillRemoveInput;
255
+ export const skillReadOutput = skillRowSchema.extend({ body: z.string() });
256
+
257
+ const recipeScope = z.enum(["session", "project", "global"]);
258
+ export const botWriteInput = z.object({
259
+ scope: z.enum(["home", "project"]),
260
+ project: z.string().optional(),
261
+ name: z.string().trim().min(1, "name is required"),
262
+ description: z.string().default(""),
263
+ instructions: z.string().default(""),
264
+ recipeScope: recipeScope.default("session"),
265
+ tools: z.array(z.string()).default([]),
266
+ skills: z.array(z.string()).default([]),
267
+ keepAlive: z.boolean().default(false),
268
+ /**
269
+ * Absent means keep the file's current value. An empty list clears it.
270
+ * A settings save that does not know these fields must not wipe them.
271
+ */
272
+ invokes: z.array(z.string().trim().min(1)).optional(),
273
+ reasoningEffort: z.enum(REASONING_EFFORTS).nullable().optional(),
274
+ modelPreferences: z.array(z.string().trim().min(1)).optional(),
275
+ /** Absent keeps the file's grants. An empty list clears them. */
276
+ mcp: z.array(botMcpGrantSchema).optional(),
277
+ });
278
+ export const botWriteOutput = z.object({ ok: z.literal(true) });
279
+ export const botRemoveInput = z.object({
280
+ scope: z.enum(["home", "project"]),
281
+ project: z.string().optional(),
282
+ name: z.string().trim().min(1, "name is required"),
283
+ });
284
+ export const botRemoveOutput = z.object({ ok: z.literal(true) });
285
+
286
+ export const compactInput = z.object({
287
+ focus: z.string().optional(),
288
+ from: z.literal("overflow").optional(),
289
+ });
290
+ export const compactOutput = z.union([
291
+ z.object({ ok: z.literal(true), compacted: z.boolean() }),
292
+ z.object({ ok: z.literal(false), reason: z.string() }),
293
+ ]);
294
+
295
+ export const renameInput = z.object({
296
+ title: z.string(),
297
+ });
298
+ export const renameOutput = z.object({
299
+ ok: z.literal(true),
300
+ title: z.string(),
301
+ });
302
+
303
+ export const evalInput = z
304
+ .object({
305
+ requestId: z.string().trim().min(1, "requestId is required"),
306
+ expr: z.string(),
307
+ source: z.string().optional(),
308
+ sourcePath: z.string().trim().min(1).optional(),
309
+ policy: z.string().trim().optional().default("auto"),
310
+ })
311
+ .refine((value) => value.source == null || value.sourcePath != null, {
312
+ message: "source requires sourcePath",
313
+ });
314
+ export const evalOutput = z.union([
315
+ z.object({
316
+ ok: z.literal(true),
317
+ turnId: z.string(),
318
+ programId: z.string(),
319
+ }),
320
+ z.object({
321
+ ok: z.literal(false),
322
+ code: z.literal("policy_unavailable"),
323
+ text: z.string(),
324
+ }),
325
+ ]);
326
+
327
+ export const turnCancelInput = z.union([
328
+ z.object({ turnId: z.string().trim().min(1) }).strict(),
329
+ z.object({ requestId: z.string().trim().min(1) }).strict(),
330
+ ]);
331
+ export const turnCancelOutput = z.object({ ok: z.literal(true) });
332
+
333
+ export const turnRewindInput = z.object({
334
+ turnId: z.string().trim().min(1),
335
+ });
336
+ export const turnRewindOutput = z.object({
337
+ ok: z.literal(true),
338
+ turnId: z.string(),
339
+ });
340
+
341
+ export const turnRetryInput = z.object({}).strict();
342
+ export const turnRetryOutput = z.object({ ok: z.literal(true) });
343
+
344
+ export const residentSetInput = z.object({ resident: z.boolean() });
345
+ export const residentSetOutput = z.object({ ok: z.literal(true) });
346
+
347
+ /** A queued row named by the id the client saw. Already drained or moved is `ok: false`. */
348
+ export const queueRowInput = z.object({ id: z.string().trim().min(1) });
349
+ export const queueRowOutput = z.object({ ok: z.boolean() });
350
+
351
+ export const agentCallsListInput = z.object({}).strict();
352
+ export const agentCallsListOutput = z.object({
353
+ calls: z.array(
354
+ z.object({
355
+ requestId: z.string(),
356
+ callerSessionId: z.string(),
357
+ tool: z.string(),
358
+ workerSessionId: z.string(),
359
+ contributionRevision: z.string(),
360
+ state: z.enum(["admitted", "accepted", "running", "paused", "terminal"]),
361
+ blocker: z.string().nullable(),
362
+ delivery: z.enum(["pending", "delivered"]),
363
+ outcome: z.unknown().nullable(),
364
+ }),
365
+ ),
366
+ });
367
+
368
+ export const agentCallsInspectInput = z.object({
369
+ requestId: z.string().trim().min(1),
370
+ });
371
+ export const agentCallsInspectOutput = z.union([
372
+ agentCallsListOutput.shape.calls.element,
373
+ z.object({ ok: z.literal(false), reason: z.string() }),
374
+ ]);
375
+
376
+ export const agentCallsCancelInput = z.object({
377
+ requestId: z.string().trim().min(1),
378
+ });
379
+ export const agentCallsCancelOutput = z.object({ ok: z.boolean() });
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { generateArtifacts } from "./generate.js";
7
+
8
+ // dist/spec/cli.js -> packages/harness-contract; the spec lives in its spec/ folder.
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const packageRoot = resolve(here, "..", "..");
11
+ const defaultOut = resolve(packageRoot, "spec");
12
+
13
+ const args = process.argv.slice(2);
14
+ const check = args.includes("--check");
15
+ const outFlag = args.indexOf("--out");
16
+ const out = outFlag === -1 ? defaultOut : resolve(args[outFlag + 1] ?? defaultOut);
17
+
18
+ const manifest: unknown = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
19
+ const version =
20
+ typeof manifest === "object" && manifest !== null && "version" in manifest && typeof manifest.version === "string"
21
+ ? manifest.version
22
+ : "0.0.0";
23
+
24
+ const artifacts = await generateArtifacts(version);
25
+
26
+ if (check) {
27
+ const stale = artifacts.filter((artifact) => {
28
+ try {
29
+ return readFileSync(join(out, artifact.file), "utf8") !== artifact.text;
30
+ } catch {
31
+ return true;
32
+ }
33
+ });
34
+ if (stale.length > 0) {
35
+ console.error(
36
+ `spec/ is stale: ${stale.map((artifact) => artifact.file).join(", ")}. Run: pnpm --filter @inhuman.tools/harness-contract spec`,
37
+ );
38
+ process.exit(1);
39
+ }
40
+ console.log("spec/ is up to date");
41
+ } else {
42
+ mkdirSync(out, { recursive: true });
43
+ for (const artifact of artifacts) writeFileSync(join(out, artifact.file), artifact.text);
44
+ console.log(`wrote ${artifacts.map((artifact) => artifact.file).join(", ")} to ${out}`);
45
+ }
@@ -0,0 +1,115 @@
1
+ import { OpenAPIGenerator, type JSONSchema } from "@orpc/openapi";
2
+ import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
3
+
4
+ import { contract, httpPathOf, listProcedures, type ContractEntry } from "../contract.js";
5
+ import { renderReadme } from "./readme.js";
6
+
7
+ type Json = Record<string, unknown>;
8
+ type JsonSchemaObject = Exclude<JSONSchema, boolean>;
9
+
10
+ const converter = new ZodToJsonSchemaConverter();
11
+
12
+ const INFO = (version: string) => ({
13
+ title: "inhuman-harness daemon RPC",
14
+ version,
15
+ });
16
+
17
+ /**
18
+ * The OpenAPI view of the contract. This is the HTTP projection (one POST per
19
+ * method, params as the JSON body); the daemon itself speaks JSON-RPC over a
20
+ * WebSocket, see `openrpc.json` for the wire-accurate description.
21
+ */
22
+ export async function generateOpenApi(version: string): Promise<Json> {
23
+ const generator = new OpenAPIGenerator({ schemaConverters: [converter] });
24
+ const document = (await generator.generate(contract, {
25
+ info: {
26
+ ...INFO(version),
27
+ description:
28
+ "HTTP projection of the daemon contract, generated from @inhuman.tools/harness-contract. " +
29
+ "The daemon's only transport today is JSON-RPC 2.0 over WebSocket (see openrpc.json); each operation here " +
30
+ "names its wire method in `x-rpc-method`.",
31
+ },
32
+ })) as unknown as Json;
33
+ const byPath = new Map<string, ContractEntry>(listProcedures().map((entry) => [httpPathOf(entry.meta), entry]));
34
+ const paths = (document.paths ?? {}) as Record<string, Record<string, Json>>;
35
+ for (const [path, item] of Object.entries(paths)) {
36
+ const entry = byPath.get(path);
37
+ const operation = item.post;
38
+ if (entry === undefined || operation === undefined) continue;
39
+ operation["x-rpc-method"] = entry.meta.method;
40
+ operation["x-host"] = entry.meta.host;
41
+ if (entry.meta.mayPark === true) operation["x-may-park"] = true;
42
+ }
43
+ return document;
44
+ }
45
+
46
+ function schemaOf(schema: ContractEntry["procedure"]["~orpc"]["inputSchema"], strategy: "input" | "output") {
47
+ const [, json] = converter.convert(schema, { strategy });
48
+ return json;
49
+ }
50
+
51
+ function isObjectSchema(schema: JsonSchemaObject): boolean {
52
+ return schema.type === "object" && schema.anyOf === undefined && schema.oneOf === undefined;
53
+ }
54
+
55
+ /**
56
+ * OpenRPC 1.3 description of the wire. Method names come from `meta.method`.
57
+ * Params are by-name: an object input contributes one param per property; an
58
+ * input that is not a plain object (a union) is given whole in `x-params-schema`.
59
+ */
60
+ export async function generateOpenRpc(version: string): Promise<Json> {
61
+ const methods: Json[] = [];
62
+ for (const entry of listProcedures()) {
63
+ const def = entry.procedure["~orpc"];
64
+ const input = schemaOf(def.inputSchema, "input");
65
+ const output = schemaOf(def.outputSchema, "output");
66
+ const tags = def.route.tags?.map((name) => ({ name })) ?? [];
67
+ const row: Json = {
68
+ name: entry.meta.method,
69
+ summary: def.route.summary,
70
+ ...(def.route.description === undefined ? {} : { description: def.route.description }),
71
+ tags,
72
+ paramStructure: "by-name",
73
+ params: [],
74
+ result: { name: "result", schema: output },
75
+ "x-host": entry.meta.host,
76
+ ...(entry.meta.mayPark === true ? { "x-may-park": true } : {}),
77
+ };
78
+ if (typeof input !== "boolean" && isObjectSchema(input)) {
79
+ const required = new Set(input.required);
80
+ row.params = Object.entries(input.properties ?? {}).map(([name, schema]) => ({
81
+ name,
82
+ required: required.has(name),
83
+ schema,
84
+ }));
85
+ } else {
86
+ row["x-params-schema"] = input;
87
+ }
88
+ methods.push(row);
89
+ }
90
+ return {
91
+ openrpc: "1.3.2",
92
+ info: {
93
+ ...INFO(version),
94
+ description:
95
+ "JSON-RPC 2.0 over WebSocket text frames on `<root>/daemon.sock`. Generated from @inhuman.tools/harness-contract.",
96
+ },
97
+ methods,
98
+ };
99
+ }
100
+
101
+ export interface Artifact {
102
+ readonly file: string;
103
+ readonly text: string;
104
+ }
105
+
106
+ const text = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
107
+
108
+ /** Every generated file, by name relative to the package's `spec/` folder. */
109
+ export async function generateArtifacts(version: string): Promise<Artifact[]> {
110
+ return [
111
+ { file: "openapi.json", text: text(await generateOpenApi(version)) },
112
+ { file: "openrpc.json", text: text(await generateOpenRpc(version)) },
113
+ { file: "README.md", text: renderReadme(listProcedures()) },
114
+ ];
115
+ }
@@ -0,0 +1,126 @@
1
+ import type { ContractEntry } from "../contract.js";
2
+
3
+ function methodTable(entries: readonly ContractEntry[], host: string): string {
4
+ const rows = entries
5
+ .filter((entry) => entry.meta.host === host)
6
+ .map((entry) => {
7
+ const summary = entry.procedure["~orpc"].route.summary ?? "";
8
+ const park = entry.meta.mayPark === true ? "yes" : "";
9
+ return `| \`${entry.meta.method}\` | \`${entry.path.join(".")}\` | ${park} | ${summary} |`;
10
+ });
11
+ return ["| Wire method | Client path | May park | Summary |", "| --- | --- | --- | --- |", ...rows].join("\n");
12
+ }
13
+
14
+ /** `packages/harness-contract/spec/README.md`. Generated: edit this function, then run the `spec` script. */
15
+ export function renderReadme(entries: readonly ContractEntry[]): string {
16
+ return `# Daemon RPC
17
+
18
+ <!-- Generated by \`pnpm --filter @inhuman.tools/harness-contract spec\`. Do not edit by hand. -->
19
+
20
+ The daemon's RPC surface is declared once, as an oRPC contract, in
21
+ [\`@inhuman.tools/harness-contract\`](../../packages/harness-contract). The
22
+ daemons implement it, [\`@inhuman.tools/harness-client\`](../../packages/harness-client)
23
+ calls it, and the files here are generated from it:
24
+
25
+ - [\`openrpc.json\`](./openrpc.json): OpenRPC 1.3, wire-accurate (JSON-RPC method names, by-name params).
26
+ - [\`openapi.json\`](./openapi.json): OpenAPI 3.1, the HTTP projection of the same contract (one POST per method).
27
+
28
+ ## Transport
29
+
30
+ JSON-RPC 2.0 in WebSocket **text** frames on the unix socket \`<root>/daemon.sock\`.
31
+ Params are always a by-name object. **Binary** frames on the same socket carry
32
+ read-only Yjs document sync and are not part of this contract.
33
+
34
+ The host is chosen by the upgrade path:
35
+
36
+ | Upgrade | Host |
37
+ | --- | --- |
38
+ | \`/kernel\` | KernelDaemon |
39
+ | \`/project?path=<dir>\` | ProjectDaemon (owns no methods) |
40
+ | \`/session?sessionId=<id>\` | SessionDaemon of an existing session |
41
+ | \`/session?path=<dir>[&sessionId=<id>]\` | SessionDaemon, created if absent |
42
+
43
+ A method a host does not own falls through session, then project, then kernel.
44
+ A session socket can therefore call kernel methods; a kernel socket answers a
45
+ session method with \`-32601\`.
46
+
47
+ ## Errors
48
+
49
+ | Code | Meaning |
50
+ | --- | --- |
51
+ | \`-32700\` | Parse error |
52
+ | \`-32600\` | Invalid request |
53
+ | \`-32601\` | Method not found |
54
+ | \`-32602\` | Invalid params (the params failed their schema) |
55
+ | \`-32603\` | Internal error, including a method result that fails its own output schema |
56
+
57
+ A thrown error with an integer \`code\` keeps that code.
58
+
59
+ ## Parks (UI forms)
60
+
61
+ A method marked *may park* can answer \`{ "form": "ui", "pending": "<id>", "body": "<scheme>" }\`
62
+ instead of its result; its output is then not validated. The caller answers with
63
+ \`ui/resolve { pending, kind, ... }\` (\`kind\` is \`accept\`, \`cancel\`, \`text\` with \`input\`,
64
+ \`opened\` with \`url\`, or \`dismiss\`). The reply to \`ui/resolve\` is the resumed call's
65
+ result, or another park form. Closing the socket cancels every park it owns.
66
+ In the contract a parking method's output is \`result | UiForm\`.
67
+
68
+ ## Using the contract from an embedder
69
+
70
+ \`\`\`ts
71
+ import { createHarnessClient, harnessUpgradePath } from "@inhuman.tools/harness-client";
72
+ import { connectUnixSocket } from "@inhuman.tools/harness-client/node";
73
+
74
+ const transport = await connectUnixSocket({
75
+ socketPath: "<root>/daemon.sock",
76
+ target: { host: "session", sessionId },
77
+ });
78
+ const client = createHarnessClient({ transport, onPark: (form) => ui.answer(form) });
79
+ await client.session.kv.set({ key: "model", value: "m", scope: "session" });
80
+ \`\`\`
81
+
82
+ In a browser or React Native use \`connectWebSocket(url)\` from the main entry
83
+ (\`harnessUpgradePath\` builds the path part). Other transports implement the
84
+ four-member \`Transport\` interface.
85
+
86
+ Cancelling a call with an \`AbortSignal\` rejects locally; there is no wire
87
+ cancel, so the daemon keeps working on it.
88
+
89
+ ## Regenerating
90
+
91
+ \`\`\`sh
92
+ pnpm --filter @inhuman.tools/harness-contract spec # write packages/harness-contract/spec/*
93
+ pnpm --filter @inhuman.tools/harness-contract spec:check # exit 1 if stale
94
+ \`\`\`
95
+
96
+ ## Client migration note
97
+
98
+ The web app (\`packages/harness-web-app/src/shell\`), the iOS app and the TUI
99
+ still call through an untyped \`request(method: string, params: unknown)\`.
100
+ They cannot import \`@inhuman.tools/harness\` (it depends on the web app) but they
101
+ can depend on \`harness-contract\` and \`harness-client\`, which depend on neither.
102
+ Migrate one call site at a time: replace \`request("kv/set", {...})\` with
103
+ \`client.session.kv.set({...})\`, or wrap the existing socket in a \`Transport\`.
104
+
105
+ ## Next step: HTTP for SaaS
106
+
107
+ The contract is already shaped for HTTP (each procedure has a POST route).
108
+ Serving it to remote callers is \`implement(contract)\` plus oRPC's
109
+ \`OpenAPIHandler\` over the same method bodies the daemons run today, behind the
110
+ host's own auth. Not built yet; the JSON-RPC transport remains the only server transport.
111
+
112
+ ## Methods
113
+
114
+ ### Session host
115
+
116
+ ${methodTable(entries, "session")}
117
+
118
+ ### Kernel host
119
+
120
+ ${methodTable(entries, "kernel")}
121
+
122
+ ### Any host
123
+
124
+ ${methodTable(entries, "any")}
125
+ `;
126
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.