@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.
- package/LICENSE.md +7 -0
- package/dist/contract.d.ts +1910 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +353 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonrpc.d.ts +18 -0
- package/dist/jsonrpc.d.ts.map +1 -0
- package/dist/jsonrpc.js +34 -0
- package/dist/jsonrpc.js.map +1 -0
- package/dist/schemas/authorization.d.ts +1721 -0
- package/dist/schemas/authorization.d.ts.map +1 -0
- package/dist/schemas/authorization.js +289 -0
- package/dist/schemas/authorization.js.map +1 -0
- package/dist/schemas/connection.d.ts +133 -0
- package/dist/schemas/connection.d.ts.map +1 -0
- package/dist/schemas/connection.js +55 -0
- package/dist/schemas/connection.js.map +1 -0
- package/dist/schemas/rpc.d.ts +86 -0
- package/dist/schemas/rpc.d.ts.map +1 -0
- package/dist/schemas/rpc.js +66 -0
- package/dist/schemas/rpc.js.map +1 -0
- package/dist/schemas/shapes.d.ts +514 -0
- package/dist/schemas/shapes.d.ts.map +1 -0
- package/dist/schemas/shapes.js +342 -0
- package/dist/schemas/shapes.js.map +1 -0
- package/dist/spec/cli.d.ts +3 -0
- package/dist/spec/cli.d.ts.map +1 -0
- package/dist/spec/cli.js +40 -0
- package/dist/spec/cli.js.map +1 -0
- package/dist/spec/generate.d.ts +21 -0
- package/dist/spec/generate.d.ts.map +1 -0
- package/dist/spec/generate.js +100 -0
- package/dist/spec/generate.js.map +1 -0
- package/dist/spec/readme.d.ts +4 -0
- package/dist/spec/readme.d.ts.map +1 -0
- package/dist/spec/readme.js +124 -0
- package/dist/spec/readme.js.map +1 -0
- package/package.json +57 -3
- package/src/contract.ts +418 -0
- package/src/index.ts +6 -0
- package/src/jsonrpc.ts +42 -0
- package/src/schemas/authorization.ts +312 -0
- package/src/schemas/connection.ts +68 -0
- package/src/schemas/rpc.ts +88 -0
- package/src/schemas/shapes.ts +379 -0
- package/src/spec/cli.ts +45 -0
- package/src/spec/generate.ts +115 -0
- package/src/spec/readme.ts +126 -0
- package/README.md +0 -3
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
function methodTable(entries, host) {
|
|
2
|
+
const rows = entries
|
|
3
|
+
.filter((entry) => entry.meta.host === host)
|
|
4
|
+
.map((entry) => {
|
|
5
|
+
const summary = entry.procedure["~orpc"].route.summary ?? "";
|
|
6
|
+
const park = entry.meta.mayPark === true ? "yes" : "";
|
|
7
|
+
return `| \`${entry.meta.method}\` | \`${entry.path.join(".")}\` | ${park} | ${summary} |`;
|
|
8
|
+
});
|
|
9
|
+
return ["| Wire method | Client path | May park | Summary |", "| --- | --- | --- | --- |", ...rows].join("\n");
|
|
10
|
+
}
|
|
11
|
+
/** `packages/harness-contract/spec/README.md`. Generated: edit this function, then run the `spec` script. */
|
|
12
|
+
export function renderReadme(entries) {
|
|
13
|
+
return `# Daemon RPC
|
|
14
|
+
|
|
15
|
+
<!-- Generated by \`pnpm --filter @inhuman.tools/harness-contract spec\`. Do not edit by hand. -->
|
|
16
|
+
|
|
17
|
+
The daemon's RPC surface is declared once, as an oRPC contract, in
|
|
18
|
+
[\`@inhuman.tools/harness-contract\`](../../packages/harness-contract). The
|
|
19
|
+
daemons implement it, [\`@inhuman.tools/harness-client\`](../../packages/harness-client)
|
|
20
|
+
calls it, and the files here are generated from it:
|
|
21
|
+
|
|
22
|
+
- [\`openrpc.json\`](./openrpc.json): OpenRPC 1.3, wire-accurate (JSON-RPC method names, by-name params).
|
|
23
|
+
- [\`openapi.json\`](./openapi.json): OpenAPI 3.1, the HTTP projection of the same contract (one POST per method).
|
|
24
|
+
|
|
25
|
+
## Transport
|
|
26
|
+
|
|
27
|
+
JSON-RPC 2.0 in WebSocket **text** frames on the unix socket \`<root>/daemon.sock\`.
|
|
28
|
+
Params are always a by-name object. **Binary** frames on the same socket carry
|
|
29
|
+
read-only Yjs document sync and are not part of this contract.
|
|
30
|
+
|
|
31
|
+
The host is chosen by the upgrade path:
|
|
32
|
+
|
|
33
|
+
| Upgrade | Host |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| \`/kernel\` | KernelDaemon |
|
|
36
|
+
| \`/project?path=<dir>\` | ProjectDaemon (owns no methods) |
|
|
37
|
+
| \`/session?sessionId=<id>\` | SessionDaemon of an existing session |
|
|
38
|
+
| \`/session?path=<dir>[&sessionId=<id>]\` | SessionDaemon, created if absent |
|
|
39
|
+
|
|
40
|
+
A method a host does not own falls through session, then project, then kernel.
|
|
41
|
+
A session socket can therefore call kernel methods; a kernel socket answers a
|
|
42
|
+
session method with \`-32601\`.
|
|
43
|
+
|
|
44
|
+
## Errors
|
|
45
|
+
|
|
46
|
+
| Code | Meaning |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| \`-32700\` | Parse error |
|
|
49
|
+
| \`-32600\` | Invalid request |
|
|
50
|
+
| \`-32601\` | Method not found |
|
|
51
|
+
| \`-32602\` | Invalid params (the params failed their schema) |
|
|
52
|
+
| \`-32603\` | Internal error, including a method result that fails its own output schema |
|
|
53
|
+
|
|
54
|
+
A thrown error with an integer \`code\` keeps that code.
|
|
55
|
+
|
|
56
|
+
## Parks (UI forms)
|
|
57
|
+
|
|
58
|
+
A method marked *may park* can answer \`{ "form": "ui", "pending": "<id>", "body": "<scheme>" }\`
|
|
59
|
+
instead of its result; its output is then not validated. The caller answers with
|
|
60
|
+
\`ui/resolve { pending, kind, ... }\` (\`kind\` is \`accept\`, \`cancel\`, \`text\` with \`input\`,
|
|
61
|
+
\`opened\` with \`url\`, or \`dismiss\`). The reply to \`ui/resolve\` is the resumed call's
|
|
62
|
+
result, or another park form. Closing the socket cancels every park it owns.
|
|
63
|
+
In the contract a parking method's output is \`result | UiForm\`.
|
|
64
|
+
|
|
65
|
+
## Using the contract from an embedder
|
|
66
|
+
|
|
67
|
+
\`\`\`ts
|
|
68
|
+
import { createHarnessClient, harnessUpgradePath } from "@inhuman.tools/harness-client";
|
|
69
|
+
import { connectUnixSocket } from "@inhuman.tools/harness-client/node";
|
|
70
|
+
|
|
71
|
+
const transport = await connectUnixSocket({
|
|
72
|
+
socketPath: "<root>/daemon.sock",
|
|
73
|
+
target: { host: "session", sessionId },
|
|
74
|
+
});
|
|
75
|
+
const client = createHarnessClient({ transport, onPark: (form) => ui.answer(form) });
|
|
76
|
+
await client.session.kv.set({ key: "model", value: "m", scope: "session" });
|
|
77
|
+
\`\`\`
|
|
78
|
+
|
|
79
|
+
In a browser or React Native use \`connectWebSocket(url)\` from the main entry
|
|
80
|
+
(\`harnessUpgradePath\` builds the path part). Other transports implement the
|
|
81
|
+
four-member \`Transport\` interface.
|
|
82
|
+
|
|
83
|
+
Cancelling a call with an \`AbortSignal\` rejects locally; there is no wire
|
|
84
|
+
cancel, so the daemon keeps working on it.
|
|
85
|
+
|
|
86
|
+
## Regenerating
|
|
87
|
+
|
|
88
|
+
\`\`\`sh
|
|
89
|
+
pnpm --filter @inhuman.tools/harness-contract spec # write packages/harness-contract/spec/*
|
|
90
|
+
pnpm --filter @inhuman.tools/harness-contract spec:check # exit 1 if stale
|
|
91
|
+
\`\`\`
|
|
92
|
+
|
|
93
|
+
## Client migration note
|
|
94
|
+
|
|
95
|
+
The web app (\`packages/harness-web-app/src/shell\`), the iOS app and the TUI
|
|
96
|
+
still call through an untyped \`request(method: string, params: unknown)\`.
|
|
97
|
+
They cannot import \`@inhuman.tools/harness\` (it depends on the web app) but they
|
|
98
|
+
can depend on \`harness-contract\` and \`harness-client\`, which depend on neither.
|
|
99
|
+
Migrate one call site at a time: replace \`request("kv/set", {...})\` with
|
|
100
|
+
\`client.session.kv.set({...})\`, or wrap the existing socket in a \`Transport\`.
|
|
101
|
+
|
|
102
|
+
## Next step: HTTP for SaaS
|
|
103
|
+
|
|
104
|
+
The contract is already shaped for HTTP (each procedure has a POST route).
|
|
105
|
+
Serving it to remote callers is \`implement(contract)\` plus oRPC's
|
|
106
|
+
\`OpenAPIHandler\` over the same method bodies the daemons run today, behind the
|
|
107
|
+
host's own auth. Not built yet; the JSON-RPC transport remains the only server transport.
|
|
108
|
+
|
|
109
|
+
## Methods
|
|
110
|
+
|
|
111
|
+
### Session host
|
|
112
|
+
|
|
113
|
+
${methodTable(entries, "session")}
|
|
114
|
+
|
|
115
|
+
### Kernel host
|
|
116
|
+
|
|
117
|
+
${methodTable(entries, "kernel")}
|
|
118
|
+
|
|
119
|
+
### Any host
|
|
120
|
+
|
|
121
|
+
${methodTable(entries, "any")}
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=readme.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"readme.js","sourceRoot":"","sources":["../../src/spec/readme.ts"],"names":[],"mappings":"AAEA,SAAS,WAAW,CAAC,OAAiC,EAAE,IAAY;IAClE,MAAM,IAAI,GAAG,OAAO;SACjB,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC;SAC3C,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;QACb,MAAM,OAAO,GAAG,KAAK,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,EAAE,CAAC;QAC7D,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QACtD,OAAO,OAAO,KAAK,CAAC,IAAI,CAAC,MAAM,UAAU,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,IAAI,MAAM,OAAO,IAAI,CAAC;IAC7F,CAAC,CAAC,CAAC;IACL,OAAO,CAAC,oDAAoD,EAAE,2BAA2B,EAAE,GAAG,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACjH,CAAC;AAED,6GAA6G;AAC7G,MAAM,UAAU,YAAY,CAAC,OAAiC;IAC5D,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAoGP,WAAW,CAAC,OAAO,EAAE,SAAS,CAAC;;;;EAI/B,WAAW,CAAC,OAAO,EAAE,QAAQ,CAAC;;;;EAI9B,WAAW,CAAC,OAAO,EAAE,KAAK,CAAC;CAC5B,CAAC;AACF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inhuman.tools/harness-contract",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The daemon's RPC contract: zod schemas and the oRPC contract router, the single source of truth for the wire.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"!**/__tests__"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@orpc/contract": "1.15.7",
|
|
23
|
+
"zod": "^4.4.3"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@orpc/openapi": "1.15.7",
|
|
27
|
+
"@orpc/zod": "1.15.7",
|
|
28
|
+
"@types/node": "^24.0.0",
|
|
29
|
+
"eslint": "9.39.2",
|
|
30
|
+
"typescript": "6.0.2",
|
|
31
|
+
"vitest": "4.1.0",
|
|
32
|
+
"@-internal-/eslint-configs": "0.0.0"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/here-build/inhuman-harness.git",
|
|
38
|
+
"directory": "packages/harness-contract"
|
|
39
|
+
},
|
|
40
|
+
"browserslist": [
|
|
41
|
+
"last 2 chrome versions",
|
|
42
|
+
"last 2 firefox versions",
|
|
43
|
+
"last 2 safari versions",
|
|
44
|
+
"ios_saf >= 16.4",
|
|
45
|
+
"node >= 22"
|
|
46
|
+
],
|
|
47
|
+
"author": "Merkle Bonsai",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -p tsconfig.build.json",
|
|
53
|
+
"lint": "eslint .",
|
|
54
|
+
"lint:fix": "eslint . --fix",
|
|
55
|
+
"spec": "pnpm build && node ./dist/spec/cli.js",
|
|
56
|
+
"spec:check": "pnpm build && node ./dist/spec/cli.js --check",
|
|
57
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
58
|
+
"test": "tsc -p tsconfig.json --noEmit && vitest run"
|
|
59
|
+
}
|
|
6
60
|
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { type AnyContractRouter, type ContractProcedure, isContractProcedure, oc } from "@orpc/contract";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import * as authorization from "./schemas/authorization.js";
|
|
5
|
+
import * as connection from "./schemas/connection.js";
|
|
6
|
+
import { uiFormSchema, uiResolveInput } from "./schemas/rpc.js";
|
|
7
|
+
import * as shapes from "./schemas/shapes.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Which daemon owns the method. A method a host does not own falls through
|
|
11
|
+
* session, then project, then kernel, so a session socket can also call a
|
|
12
|
+
* kernel method; a kernel socket cannot call a session method (-32601).
|
|
13
|
+
* `any` is built into every host's request dispatch (`ui/resolve`).
|
|
14
|
+
*/
|
|
15
|
+
export type HarnessHost = "session" | "kernel" | "any";
|
|
16
|
+
|
|
17
|
+
/** Carried by every procedure. `method` is the JSON-RPC wire name. */
|
|
18
|
+
export interface HarnessMeta {
|
|
19
|
+
readonly method: string;
|
|
20
|
+
readonly host: HarnessHost;
|
|
21
|
+
/** The method may answer `{form:"ui", pending, body}` instead of its result; the caller finishes it with `ui/resolve`. */
|
|
22
|
+
readonly mayPark?: true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A contract procedure of this protocol: zod in and out, wire identity in `meta`. */
|
|
26
|
+
export type HarnessProcedure<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodType> = ContractProcedure<
|
|
27
|
+
I,
|
|
28
|
+
O,
|
|
29
|
+
any,
|
|
30
|
+
HarnessMeta
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
interface Doc {
|
|
34
|
+
readonly summary: string;
|
|
35
|
+
readonly description?: string;
|
|
36
|
+
readonly mayPark?: true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The HTTP path a procedure is projected to in the OpenAPI document. */
|
|
40
|
+
export function httpPathOf(meta: HarnessMeta): `/${string}` {
|
|
41
|
+
return meta.host === "any" ? `/${meta.method}` : `/${meta.host}/${meta.method}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rpc(host: HarnessHost, method: string, doc: Doc) {
|
|
45
|
+
const meta: HarnessMeta = doc.mayPark === true ? { method, host, mayPark: true } : { method, host };
|
|
46
|
+
const namespace = method.includes("/") ? method.slice(0, method.indexOf("/")) : method;
|
|
47
|
+
return oc.$meta<HarnessMeta>(meta).route({
|
|
48
|
+
method: "POST",
|
|
49
|
+
path: httpPathOf(meta),
|
|
50
|
+
summary: doc.summary,
|
|
51
|
+
...(doc.description === undefined ? {} : { description: doc.description }),
|
|
52
|
+
tags: [host === "any" ? "ui" : `${host}.${namespace}`],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A parking method's output: its result, or the form to answer. */
|
|
57
|
+
const parks = <T extends z.ZodType>(result: T) => z.union([result, uiFormSchema]);
|
|
58
|
+
|
|
59
|
+
const session = {
|
|
60
|
+
kv: {
|
|
61
|
+
set: rpc("session", "kv/set", {
|
|
62
|
+
summary: "Write a key in session, project or kernel scope",
|
|
63
|
+
description:
|
|
64
|
+
'Session `model` / `connection` also become the last-choice default (project + kernel) when `remember` is set. `/model` and `/connect` send "1"; a bare session write (subagent, `--model`, `--connection`) does not.',
|
|
65
|
+
})
|
|
66
|
+
.input(shapes.kvSetInput)
|
|
67
|
+
.output(shapes.kvOutput),
|
|
68
|
+
clear: rpc("session", "kv/clear", { summary: "Clear a key in session, project or kernel scope" })
|
|
69
|
+
.input(shapes.kvClearInput)
|
|
70
|
+
.output(shapes.kvOutput),
|
|
71
|
+
},
|
|
72
|
+
plugin: {
|
|
73
|
+
method: rpc("session", "plugin/method", {
|
|
74
|
+
summary: "Call a plugin's own state method",
|
|
75
|
+
description:
|
|
76
|
+
"A plugin command's call into that plugin's own state. `args` is a JSON object, since action params cross as strings. `field` names the argument the composer text (`input`) fills; an empty `input` then refuses. A failure is data (`ok: false`), not an RPC error.",
|
|
77
|
+
})
|
|
78
|
+
.input(shapes.pluginMethodInput)
|
|
79
|
+
.output(shapes.pluginMethodOutput),
|
|
80
|
+
},
|
|
81
|
+
policy: {
|
|
82
|
+
set: rpc("session", "policy/set", { summary: "Set the session's permission mode" })
|
|
83
|
+
.input(shapes.policySetInput)
|
|
84
|
+
.output(shapes.policySetOutput),
|
|
85
|
+
explain: rpc("session", "policy/explain", {
|
|
86
|
+
summary: "Explain what would happen to a call right now",
|
|
87
|
+
description:
|
|
88
|
+
"Runs the same floor, grant and resolution code as a real call, with no judge and no effect. Returns the tier (block, soft-deny, ask, allow), the classes that voted, which resolver licenses it in the session's mode and from which layer, and the outcome.",
|
|
89
|
+
})
|
|
90
|
+
.input(authorization.policyExplainInput)
|
|
91
|
+
.output(authorization.policyExplainOutput),
|
|
92
|
+
overlay: rpc("session", "policy/overlay", {
|
|
93
|
+
summary: "Set or clear this session's authorization overlay",
|
|
94
|
+
description:
|
|
95
|
+
"A human decision for this session only. It may loosen what the user's config says, but never past the project layer. Refused for any caller that is not a human client.",
|
|
96
|
+
})
|
|
97
|
+
.input(authorization.policyOverlayInput)
|
|
98
|
+
.output(authorization.policyOverlayOutput),
|
|
99
|
+
configGet: rpc("session", "policy/config-get", {
|
|
100
|
+
summary: "Read the authorization layers this session resolves against",
|
|
101
|
+
})
|
|
102
|
+
.input(authorization.policyConfigGetInput)
|
|
103
|
+
.output(authorization.policyConfigGetOutput),
|
|
104
|
+
},
|
|
105
|
+
connect: {
|
|
106
|
+
adopt: rpc("session", "connect/adopt", {
|
|
107
|
+
summary: "Adopt a connection and make it this session's connection",
|
|
108
|
+
description:
|
|
109
|
+
"Delegates to the kernel's `connect/adopt`, then writes the new connection id into the session's `connection` key and warms it. May park for UI.",
|
|
110
|
+
mayPark: true,
|
|
111
|
+
})
|
|
112
|
+
.input(shapes.connectAdoptInput)
|
|
113
|
+
.output(shapes.sessionConnectAdoptOutput),
|
|
114
|
+
},
|
|
115
|
+
turn: {
|
|
116
|
+
send: rpc("session", "turn", {
|
|
117
|
+
summary: "Submit a prompt as a turn, a steer or a queued prompt",
|
|
118
|
+
description:
|
|
119
|
+
"Pictures (`visuals`) ride only on a fresh turn: standard base64, stored beside the session, and the model must accept images.",
|
|
120
|
+
})
|
|
121
|
+
.input(shapes.turnInput)
|
|
122
|
+
.output(shapes.turnOutput),
|
|
123
|
+
cancel: rpc("session", "turn/cancel", { summary: "Cancel a running turn by turn id or eval request id" })
|
|
124
|
+
.input(shapes.turnCancelInput)
|
|
125
|
+
.output(shapes.turnCancelOutput),
|
|
126
|
+
visual: rpc("session", "turn/visual", { summary: "Read one picture seated on the session's head turn" })
|
|
127
|
+
.input(shapes.turnVisualReadInput)
|
|
128
|
+
.output(shapes.turnVisualReadOutput),
|
|
129
|
+
rewind: rpc("session", "turn/rewind", { summary: "Rewind the session to before a turn" })
|
|
130
|
+
.input(shapes.turnRewindInput)
|
|
131
|
+
.output(shapes.turnRewindOutput),
|
|
132
|
+
retry: rpc("session", "turn/retry", { summary: "Retry the last turn" })
|
|
133
|
+
.input(shapes.turnRetryInput)
|
|
134
|
+
.output(shapes.turnRetryOutput),
|
|
135
|
+
},
|
|
136
|
+
blob: {
|
|
137
|
+
get: rpc("session", "blob/get", {
|
|
138
|
+
summary: "Read one blob the session's document references, by content hash",
|
|
139
|
+
description:
|
|
140
|
+
"Large bodies (a file's prior text today) live in the kernel's blob bucket; the document keeps `{sha256, bytes, mime, head}`. `text/*` blobs return `text`, anything else `bytesBase64`. The hash a document names is the capability; there is no listing.",
|
|
141
|
+
})
|
|
142
|
+
.input(shapes.blobGetInput)
|
|
143
|
+
.output(shapes.blobGetOutput),
|
|
144
|
+
vacuum: rpc("session", "blob/vacuum", {
|
|
145
|
+
summary: "Move the session's large stored text into the blob bucket",
|
|
146
|
+
description:
|
|
147
|
+
"A session written before large bodies left the document keeps a file's prior text and an isolated completion's prompt inline, and the stored image carries them forever. The vacuum moves every such body over the inline limit into the kernel's blob bucket, leaving the head and a reference, then rewrites the stored image as one snapshot. Completions still running are skipped. `dryRun` counts what would move and writes nothing. Reads stay whole: the wire projection and `blob/get` resolve the references.",
|
|
148
|
+
})
|
|
149
|
+
.input(shapes.blobVacuumInput)
|
|
150
|
+
.output(shapes.blobVacuumOutput),
|
|
151
|
+
},
|
|
152
|
+
compact: rpc("session", "compact", { summary: "Compact the session's context, optionally around a focus" })
|
|
153
|
+
.input(shapes.compactInput)
|
|
154
|
+
.output(shapes.compactOutput),
|
|
155
|
+
rename: rpc("session", "rename", { summary: "Set the session title" })
|
|
156
|
+
.input(shapes.renameInput)
|
|
157
|
+
.output(shapes.renameOutput),
|
|
158
|
+
eval: rpc("session", "eval", {
|
|
159
|
+
summary: "Admit a Scheme program as an unattended eval turn",
|
|
160
|
+
description:
|
|
161
|
+
"Idempotent per `requestId`: a repeat with the same input returns the admitted turn; a repeat with different input is refused. `ok: false` with `policy_unavailable` means the requested permission policy cannot run unattended.",
|
|
162
|
+
})
|
|
163
|
+
.input(shapes.evalInput)
|
|
164
|
+
.output(shapes.evalOutput),
|
|
165
|
+
ask: {
|
|
166
|
+
answer: rpc("session", "ask/answer", {
|
|
167
|
+
summary: "Answer the live approval ask",
|
|
168
|
+
description:
|
|
169
|
+
"Answer the live ask or soft-deny on `sessionId`: this session, or a worker that escalated to it. `store` remembers a human yes to an ask as a grant (scope, subject, TTL); a soft-deny is never stored and a block is never put to a human.",
|
|
170
|
+
})
|
|
171
|
+
.input(authorization.askAnswerInput)
|
|
172
|
+
.output(authorization.askAnswerOutput),
|
|
173
|
+
},
|
|
174
|
+
ack: {
|
|
175
|
+
pending: rpc("session", "ack/pending", {
|
|
176
|
+
summary: "List calls waiting for an acknowledgement",
|
|
177
|
+
description:
|
|
178
|
+
"A call that is allowed but flagged `needsAcknowledgement` waits for a person. Acks travel over RPC only: there is no ack job and no transcript entry. Absent `sessionId`: this session and the workers that escalated to it.",
|
|
179
|
+
})
|
|
180
|
+
.input(authorization.ackPendingInput)
|
|
181
|
+
.output(authorization.ackPendingOutput),
|
|
182
|
+
answer: rpc("session", "ack/answer", {
|
|
183
|
+
summary: "Acknowledge or refuse a pending call, optionally remembering the acknowledgement",
|
|
184
|
+
description:
|
|
185
|
+
"Human clients only. `accept` lets the call run; a refusal is a human denial (its `comment` is what the agent reads). `store` remembers the acknowledgement at session, project, bot or global scope as one of the offered `storeOptions`; prompt-created project, bot and global acks last 7 days. A block and a policy denial are never put here. When the prompt was also an ask (a combined prompt), the same answer settles both.",
|
|
186
|
+
})
|
|
187
|
+
.input(authorization.ackAnswerInput)
|
|
188
|
+
.output(authorization.ackAnswerOutput),
|
|
189
|
+
},
|
|
190
|
+
resident: {
|
|
191
|
+
set: rpc("session", "resident/set", { summary: "Mark the session resident (kept alive) or not" })
|
|
192
|
+
.input(shapes.residentSetInput)
|
|
193
|
+
.output(shapes.residentSetOutput),
|
|
194
|
+
},
|
|
195
|
+
queue: {
|
|
196
|
+
steerFirst: rpc("session", "queue/steer-first", {
|
|
197
|
+
summary: "Promote the first queued prompt to a steer",
|
|
198
|
+
description: "A queued row named by the id the client saw. Already drained or moved is `ok: false`.",
|
|
199
|
+
})
|
|
200
|
+
.input(shapes.queueRowInput)
|
|
201
|
+
.output(shapes.queueRowOutput),
|
|
202
|
+
retractLast: rpc("session", "queue/retract-last", {
|
|
203
|
+
summary: "Retract the last queued prompt",
|
|
204
|
+
description: "A queued row named by the id the client saw. Already drained or moved is `ok: false`.",
|
|
205
|
+
})
|
|
206
|
+
.input(shapes.queueRowInput)
|
|
207
|
+
.output(shapes.queueRowOutput),
|
|
208
|
+
},
|
|
209
|
+
agentCalls: {
|
|
210
|
+
list: rpc("session", "agent-calls/list", { summary: "List agent calls made from this session" })
|
|
211
|
+
.input(shapes.agentCallsListInput)
|
|
212
|
+
.output(shapes.agentCallsListOutput),
|
|
213
|
+
inspect: rpc("session", "agent-calls/inspect", { summary: "Inspect one agent call by request id" })
|
|
214
|
+
.input(shapes.agentCallsInspectInput)
|
|
215
|
+
.output(shapes.agentCallsInspectOutput),
|
|
216
|
+
cancel: rpc("session", "agent-calls/cancel", { summary: "Cancel one agent call by request id" })
|
|
217
|
+
.input(shapes.agentCallsCancelInput)
|
|
218
|
+
.output(shapes.agentCallsCancelOutput),
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const kernel = {
|
|
223
|
+
termContinue: {
|
|
224
|
+
set: rpc("kernel", "term-continue/set", { summary: "Remember which session a terminal key continues" })
|
|
225
|
+
.input(shapes.termContinueSetInput)
|
|
226
|
+
.output(shapes.termContinueOutput),
|
|
227
|
+
clear: rpc("kernel", "term-continue/clear", { summary: "Forget a terminal key's continue session" })
|
|
228
|
+
.input(shapes.termContinueClearInput)
|
|
229
|
+
.output(shapes.termContinueOutput),
|
|
230
|
+
},
|
|
231
|
+
connect: {
|
|
232
|
+
drop: rpc("kernel", "connect/drop", {
|
|
233
|
+
summary: "Remove a connection and its secrets",
|
|
234
|
+
description: "Without `confirm: true` it parks on a confirmation form; cancelling answers `{ok: false}`.",
|
|
235
|
+
mayPark: true,
|
|
236
|
+
})
|
|
237
|
+
.input(shapes.connectDropInput)
|
|
238
|
+
.output(parks(shapes.connectDropOutput)),
|
|
239
|
+
adopt: rpc("kernel", "connect/adopt", {
|
|
240
|
+
summary: "Adopt a connection from a driver",
|
|
241
|
+
description:
|
|
242
|
+
"Runs the driver's adopt flow. The flow may need the human (sign-in, a pasted key) and then parks. Cancelling answers `{ok: false}`.",
|
|
243
|
+
mayPark: true,
|
|
244
|
+
})
|
|
245
|
+
.input(shapes.connectAdoptInput)
|
|
246
|
+
.output(parks(shapes.connectAdoptOutput)),
|
|
247
|
+
list: rpc("kernel", "connect/list", { summary: "List connections, optionally by ability claim" })
|
|
248
|
+
.input(connection.listConnectionsInput)
|
|
249
|
+
.output(connection.listConnectionsOutput),
|
|
250
|
+
models: rpc("kernel", "connect/models", { summary: "Probe the model catalog of one or all connections" })
|
|
251
|
+
.input(connection.connectModelsInput)
|
|
252
|
+
.output(connection.connectModelsOutput),
|
|
253
|
+
get: rpc("kernel", "connect/get", { summary: "Read one connection with its settings" })
|
|
254
|
+
.input(connection.getConnectionInput)
|
|
255
|
+
.output(connection.getConnectionOutput),
|
|
256
|
+
setEnabled: rpc("kernel", "connect/set-enabled", { summary: "Enable or disable a connection" })
|
|
257
|
+
.input(connection.setEnabledInput)
|
|
258
|
+
.output(connection.setEnabledOutput),
|
|
259
|
+
alwaysAllow: rpc("kernel", "connect/always-allow", { summary: "Always allow one tool of a connection" })
|
|
260
|
+
.input(connection.persistToolInput)
|
|
261
|
+
.output(connection.persistToolOutput),
|
|
262
|
+
excludeTool: rpc("kernel", "connect/exclude-tool", { summary: "Exclude one tool of a connection" })
|
|
263
|
+
.input(connection.persistToolInput)
|
|
264
|
+
.output(connection.persistToolOutput),
|
|
265
|
+
login: rpc("kernel", "connect/login", {
|
|
266
|
+
summary: "Sign in to a connection",
|
|
267
|
+
description: "May open a browser flow and park until the human finishes it.",
|
|
268
|
+
mayPark: true,
|
|
269
|
+
})
|
|
270
|
+
.input(connection.loginInput)
|
|
271
|
+
.output(parks(connection.loginOutput)),
|
|
272
|
+
logout: rpc("kernel", "connect/logout", { summary: "Sign out of a connection" })
|
|
273
|
+
.input(connection.logoutInput)
|
|
274
|
+
.output(connection.logoutOutput),
|
|
275
|
+
update: rpc("kernel", "connect/update", {
|
|
276
|
+
summary: "Rename a connection or merge its settings",
|
|
277
|
+
description: "Rename or merge settings of a connection that already exists. The id stays.",
|
|
278
|
+
})
|
|
279
|
+
.input(shapes.connectUpdateInput)
|
|
280
|
+
.output(shapes.connectUpdateOutput),
|
|
281
|
+
},
|
|
282
|
+
mcpServer: {
|
|
283
|
+
adopt: rpc("kernel", "mcp-server/adopt", {
|
|
284
|
+
summary: "Adopt an MCP server as a connection",
|
|
285
|
+
description: "May need the human (sign-in) and then parks.",
|
|
286
|
+
mayPark: true,
|
|
287
|
+
})
|
|
288
|
+
.input(shapes.mcpServerAdoptInput)
|
|
289
|
+
.output(parks(shapes.mcpServerAdoptOutput)),
|
|
290
|
+
search: rpc("kernel", "mcp-server/search", { summary: "Search the official MCP registry" })
|
|
291
|
+
.input(shapes.mcpServerSearchInput)
|
|
292
|
+
.output(shapes.mcpServerSearchOutput),
|
|
293
|
+
},
|
|
294
|
+
config: {
|
|
295
|
+
set: rpc("kernel", "config/set", { summary: "Set kernel configuration fields" })
|
|
296
|
+
.input(shapes.configSetInput)
|
|
297
|
+
.output(shapes.configSetOutput),
|
|
298
|
+
},
|
|
299
|
+
grant: {
|
|
300
|
+
list: rpc("kernel", "grant/list", { summary: "List stored grants" })
|
|
301
|
+
.input(authorization.grantListInput)
|
|
302
|
+
.output(authorization.grantListOutput),
|
|
303
|
+
add: rpc("kernel", "grant/add", {
|
|
304
|
+
summary: "Store a grant from settings",
|
|
305
|
+
description:
|
|
306
|
+
"A grant turns an ask into allow for a family (a command, a path, an MCP server+tool+account). It never lifts a soft-deny or a block. Settings grants do not expire unless `ttlMs` is given.",
|
|
307
|
+
})
|
|
308
|
+
.input(authorization.grantAddInput)
|
|
309
|
+
.output(authorization.grantAddOutput),
|
|
310
|
+
revoke: rpc("kernel", "grant/revoke", { summary: "Revoke a stored grant" })
|
|
311
|
+
.input(authorization.grantRevokeInput)
|
|
312
|
+
.output(authorization.grantRevokeOutput),
|
|
313
|
+
},
|
|
314
|
+
ack: {
|
|
315
|
+
list: rpc("kernel", "ack/list", {
|
|
316
|
+
summary: "List stored acknowledgements",
|
|
317
|
+
description:
|
|
318
|
+
"The acknowledgements a person stored at session, project, bot or global scope (same rows, TTL and scopes as grants). Flags themselves live in the `authorization` block (see `policy/config-get`).",
|
|
319
|
+
})
|
|
320
|
+
.input(authorization.ackListInput)
|
|
321
|
+
.output(authorization.ackListOutput),
|
|
322
|
+
add: rpc("kernel", "ack/add", {
|
|
323
|
+
summary: "Store an acknowledgement from settings",
|
|
324
|
+
description:
|
|
325
|
+
'Equivalent to "you can send any email you want in this project". Settings acknowledgements do not expire unless `ttlMs` is given.',
|
|
326
|
+
})
|
|
327
|
+
.input(authorization.ackAddInput)
|
|
328
|
+
.output(authorization.ackAddOutput),
|
|
329
|
+
revoke: rpc("kernel", "ack/revoke", { summary: "Revoke a stored acknowledgement" })
|
|
330
|
+
.input(authorization.ackRevokeInput)
|
|
331
|
+
.output(authorization.ackRevokeOutput),
|
|
332
|
+
},
|
|
333
|
+
plugin: {
|
|
334
|
+
set: rpc("kernel", "plugin/set", { summary: "Enable, disable or foreground a plugin" })
|
|
335
|
+
.input(shapes.pluginSetInput)
|
|
336
|
+
.output(shapes.pluginSetOutput),
|
|
337
|
+
install: rpc("kernel", "plugin/install", { summary: "Install a plugin from an npm spec" })
|
|
338
|
+
.input(shapes.pluginInstallInput)
|
|
339
|
+
.output(shapes.pluginInstallOutput),
|
|
340
|
+
remove: rpc("kernel", "plugin/remove", { summary: "Remove an installed plugin" })
|
|
341
|
+
.input(shapes.pluginRemoveInput)
|
|
342
|
+
.output(shapes.pluginRemoveOutput),
|
|
343
|
+
},
|
|
344
|
+
skill: {
|
|
345
|
+
list: rpc("kernel", "skill/list", { summary: "List skill files for a project and home" })
|
|
346
|
+
.input(shapes.skillListInput)
|
|
347
|
+
.output(shapes.skillListOutput),
|
|
348
|
+
write: rpc("kernel", "skill/write", { summary: "Write a skill file" })
|
|
349
|
+
.input(shapes.skillWriteInput)
|
|
350
|
+
.output(shapes.skillWriteOutput),
|
|
351
|
+
remove: rpc("kernel", "skill/remove", { summary: "Remove a skill file" })
|
|
352
|
+
.input(shapes.skillRemoveInput)
|
|
353
|
+
.output(shapes.skillRemoveOutput),
|
|
354
|
+
read: rpc("kernel", "skill/read", { summary: "Read a skill file with its body" })
|
|
355
|
+
.input(shapes.skillReadInput)
|
|
356
|
+
.output(shapes.skillReadOutput),
|
|
357
|
+
},
|
|
358
|
+
bot: {
|
|
359
|
+
write: rpc("kernel", "bot/write", {
|
|
360
|
+
summary: "Write a bot definition",
|
|
361
|
+
description:
|
|
362
|
+
"Absent `invokes`, `reasoningEffort` and `modelPreferences` keep the file's current value; an empty list clears. Absent `mcp` keeps the file's grants; an empty list clears them. A settings save that does not know these fields must not wipe them.",
|
|
363
|
+
})
|
|
364
|
+
.input(shapes.botWriteInput)
|
|
365
|
+
.output(shapes.botWriteOutput),
|
|
366
|
+
remove: rpc("kernel", "bot/remove", { summary: "Remove a bot definition" })
|
|
367
|
+
.input(shapes.botRemoveInput)
|
|
368
|
+
.output(shapes.botRemoveOutput),
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const common = {
|
|
373
|
+
resolve: rpc("any", "ui/resolve", {
|
|
374
|
+
summary: "Answer a parked UI form",
|
|
375
|
+
description:
|
|
376
|
+
'Finishes a call that answered `{form: "ui", pending, body}`. The reply is the resumed call\'s result, or another park form. `kind: "cancel"` aborts the parked call. Closing the socket cancels every park it owns.',
|
|
377
|
+
})
|
|
378
|
+
.input(uiResolveInput)
|
|
379
|
+
.output(z.record(z.string(), z.unknown())),
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The daemon's RPC contract, grouped by host and namespace. The tree is for
|
|
384
|
+
* callers; the JSON-RPC wire name of each procedure is `meta.method`.
|
|
385
|
+
* `turn` is `session.turn.send`: the namespace and the bare method share a name.
|
|
386
|
+
*/
|
|
387
|
+
export const contract = { session, kernel, ui: common };
|
|
388
|
+
export type HarnessContract = typeof contract;
|
|
389
|
+
|
|
390
|
+
export interface ContractEntry {
|
|
391
|
+
/** Tree path, e.g. `["session", "kv", "set"]`. */
|
|
392
|
+
readonly path: readonly string[];
|
|
393
|
+
readonly procedure: HarnessProcedure;
|
|
394
|
+
readonly meta: HarnessMeta;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Every procedure of a router with its tree path, in declaration order. */
|
|
398
|
+
export function listProcedures(router: AnyContractRouter = contract, path: readonly string[] = []): ContractEntry[] {
|
|
399
|
+
if (isContractProcedure(router)) {
|
|
400
|
+
return [{ path, procedure: router as HarnessProcedure, meta: router["~orpc"].meta as HarnessMeta }];
|
|
401
|
+
}
|
|
402
|
+
return Object.entries(router).flatMap(([key, child]) => listProcedures(child, [...path, key]));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The wire name of a procedure. */
|
|
406
|
+
export function methodOf(procedure: HarnessProcedure): string {
|
|
407
|
+
return procedure["~orpc"].meta.method;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Wire name for a tree path (`["session","kv","set"]` is `kv/set`), or `undefined` for an unknown path. */
|
|
411
|
+
export function methodForPath(path: readonly string[]): string | undefined {
|
|
412
|
+
let node: unknown = contract;
|
|
413
|
+
for (const key of path) {
|
|
414
|
+
if (typeof node !== "object" || node === null || !Object.hasOwn(node, key)) return undefined;
|
|
415
|
+
node = (node as Record<string, unknown>)[key];
|
|
416
|
+
}
|
|
417
|
+
return isContractProcedure(node) ? (node["~orpc"].meta as HarnessMeta).method : undefined;
|
|
418
|
+
}
|
package/src/index.ts
ADDED
package/src/jsonrpc.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type JsonRpcId } from "./schemas/rpc.js";
|
|
2
|
+
|
|
3
|
+
export const JSON_RPC_VERSION = "2.0" as const;
|
|
4
|
+
export const JSON_RPC_PARSE_ERROR = -32_700;
|
|
5
|
+
export const JSON_RPC_INVALID_REQUEST = -32_600;
|
|
6
|
+
export const JSON_RPC_METHOD_NOT_FOUND = -32_601;
|
|
7
|
+
export const JSON_RPC_INVALID_PARAMS = -32_602;
|
|
8
|
+
export const JSON_RPC_INTERNAL_ERROR = -32_603;
|
|
9
|
+
|
|
10
|
+
/** JSON-RPC error on a Client waiter. Transport loss is a plain Error. */
|
|
11
|
+
export class RpcClientError extends Error {
|
|
12
|
+
readonly code: number;
|
|
13
|
+
constructor(code: number, message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "RpcClientError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Method-not-found / invalid-params: do not treat as a socket drop. */
|
|
21
|
+
export function isRpcContractError(error: unknown): boolean {
|
|
22
|
+
return (
|
|
23
|
+
error instanceof RpcClientError &&
|
|
24
|
+
(error.code === JSON_RPC_METHOD_NOT_FOUND || error.code === JSON_RPC_INVALID_PARAMS)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function encodeJsonRpcRequest(id: string | number, method: string, params: unknown = {}): string {
|
|
29
|
+
return JSON.stringify({ jsonrpc: JSON_RPC_VERSION, id, method, params });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function encodeJsonRpcResult(id: JsonRpcId, result: unknown): string {
|
|
33
|
+
return JSON.stringify({ jsonrpc: JSON_RPC_VERSION, id, result });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function encodeJsonRpcError(id: JsonRpcId, code: number, message: string, data?: unknown): string {
|
|
37
|
+
return JSON.stringify({
|
|
38
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
39
|
+
id,
|
|
40
|
+
error: data === undefined ? { code, message } : { code, message, data },
|
|
41
|
+
});
|
|
42
|
+
}
|