@intentic/sandbox-contract 1.230.2 → 1.231.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 +5 -0
- package/dist/contracts/agent.contract.d.ts +42 -6
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +6 -1
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +293 -0
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.js +10 -1
- package/dist/contracts/capabilities.contract.js.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +15 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.d.ts +12 -2
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/history.contract.d.ts +6 -1
- package/dist/contracts/history.contract.d.ts.map +1 -1
- package/dist/contracts/panels.contract.d.ts +1 -0
- package/dist/contracts/panels.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +811 -0
- package/dist/contracts/runner.contract.d.ts.map +1 -0
- package/dist/contracts/runner.contract.js +12 -0
- package/dist/contracts/runner.contract.js.map +1 -0
- package/dist/contracts/system.contract.d.ts +2 -3
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/documents.d.ts +7 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +27 -0
- package/dist/documents.js.map +1 -0
- package/dist/events.d.ts +58 -6
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +12 -1
- package/dist/events.js.map +1 -1
- package/dist/history-state.d.ts.map +1 -1
- package/dist/history-state.js +3 -0
- package/dist/history-state.js.map +1 -1
- package/dist/index.d.ts +380 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/runner-protocol.d.ts +134 -0
- package/dist/runner-protocol.d.ts.map +1 -0
- package/dist/runner-protocol.js +95 -0
- package/dist/runner-protocol.js.map +1 -0
- package/dist/schemas.d.ts +62 -9
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +30 -11
- package/dist/schemas.js.map +1 -1
- package/package.json +4 -4
- package/src/contracts/capabilities.contract.ts +16 -0
- package/src/contracts/runner.contract.ts +33 -0
- package/src/documents.test.ts +66 -0
- package/src/documents.ts +71 -0
- package/src/events.ts +44 -2
- package/src/history-state.ts +7 -0
- package/src/index.ts +4 -0
- package/src/runner-protocol.ts +209 -0
- package/src/schemas.ts +106 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentic/sandbox-contract",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.231.0",
|
|
4
4
|
"description": "oRPC wire contract for the intentic sandbox daemon, shared by the daemon and its browser client",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -89,9 +89,9 @@
|
|
|
89
89
|
"@orpc/contract": "1.14.13",
|
|
90
90
|
"tslib": "2.8.1",
|
|
91
91
|
"zod": "4.4.3",
|
|
92
|
-
"@intentic/constants": "1.
|
|
93
|
-
"@intentic/extension-manifest": "1.
|
|
94
|
-
"@intentic/registry": "1.
|
|
92
|
+
"@intentic/constants": "1.231.0",
|
|
93
|
+
"@intentic/extension-manifest": "1.231.0",
|
|
94
|
+
"@intentic/registry": "1.231.0"
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|
|
97
97
|
"@types/node": "24.13.2",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CapabilityIdParamSchema,
|
|
8
8
|
CapabilityLoginSchema,
|
|
9
9
|
CapabilityOtpSchema,
|
|
10
|
+
CapabilityProbeSchema,
|
|
10
11
|
CapabilityRenameSchema,
|
|
11
12
|
CapabilitySchema,
|
|
12
13
|
CapabilitySecretInputSchema,
|
|
@@ -48,6 +49,21 @@ export const capabilitiesContract = {
|
|
|
48
49
|
})
|
|
49
50
|
.input(CapabilitySchema)
|
|
50
51
|
.output(eventIterator(IntenticLineSchema)),
|
|
52
|
+
/* TRY THE SETTINGS BEFORE SAVING THEM. Nothing is written and nothing is applied: the daemon dials the
|
|
53
|
+
* service the way the connection would and hands back what it said. It exists because the alternative is
|
|
54
|
+
* the form's only feedback being the card afterwards reading "not connected", which names none of the six
|
|
55
|
+
* answers that could have been wrong. A credential the caller is keeping arrives as VAULTED here too, so
|
|
56
|
+
* an edit can be tested without re-typing a key. */
|
|
57
|
+
probe: oc
|
|
58
|
+
.route({
|
|
59
|
+
method: "POST",
|
|
60
|
+
path: "/capabilities/probe",
|
|
61
|
+
summary: "Test a connection's settings without saving them",
|
|
62
|
+
description:
|
|
63
|
+
"Dials the service the way this connection would and hands back what it said, before anything is written. The answer is the service's own confirmation or its exact refusal, so a wrong token or an unreachable host is found on the form rather than on a card afterwards.",
|
|
64
|
+
})
|
|
65
|
+
.input(CapabilitySchema)
|
|
66
|
+
.output(CapabilityProbeSchema),
|
|
51
67
|
remove: oc
|
|
52
68
|
.route({
|
|
53
69
|
method: "DELETE",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { eventIterator, oc } from "@orpc/contract";
|
|
2
|
+
import { AgentEventSchema } from "../events.js";
|
|
3
|
+
import { RunnerFactsSchema, RunnerSyncLineSchema, RunnerSyncSchema, RunnerTurnSchema } from "../runner-protocol.js";
|
|
4
|
+
import { OkSchema } from "../schemas.js";
|
|
5
|
+
|
|
6
|
+
/* What a RUNNER can be asked, over the socket it opened to its parent sandbox. Phase-1 skeleton: the shape is
|
|
7
|
+
* decided (docs/remote-runners-plan.md §5, workspace root), the implementations land with runner mode.
|
|
8
|
+
*
|
|
9
|
+
* Same inversion as hostContract: the runner dialled, but the runner is the oRPC SERVER and the parent holds
|
|
10
|
+
* the client, because a runner sits behind NAT or inside a Fly private network and can only ever be the side
|
|
11
|
+
* that connects. No `.route()` on these, the link never touches HTTP; the procedure path IS the address.
|
|
12
|
+
*
|
|
13
|
+
* TYPED THROUGHOUT, where hostContract keeps its deliberate `mcp` hole, and the difference is who releases
|
|
14
|
+
* what. A machine's tools must outlive the daemon's release cycle, so their schemas stay on the machine. A
|
|
15
|
+
* runner IS this daemon, the same image the parent runs, released together by construction, so an untyped
|
|
16
|
+
* channel would buy independence nobody has while costing the parent's persistence layer its guarantee that
|
|
17
|
+
* every frame is exactly what a local turn would have produced. */
|
|
18
|
+
export const runnerContract = {
|
|
19
|
+
// What this runner is, hardware-wise: pulled after the socket authenticates and again whenever the
|
|
20
|
+
// placement picker wants it fresh. Parity facts (image, overlay hash) ride the hello instead, they change
|
|
21
|
+
// only with a rebuild, which drops the socket anyway.
|
|
22
|
+
describe: oc.output(RunnerFactsSchema),
|
|
23
|
+
// Bring the runner's checkout of one conversation's branch up to date (`pull`), or return the result
|
|
24
|
+
// after a turn (`push`). Streamed: a first sync clones repositories, and a person may be watching.
|
|
25
|
+
syncWorkspace: oc.input(RunnerSyncSchema).output(eventIterator(RunnerSyncLineSchema)),
|
|
26
|
+
// The dispatch: one turn in, the frames a local turn would have produced out. The parent republishes them
|
|
27
|
+
// into the same pipeline local frames enter, which is the whole "feels local" mechanism.
|
|
28
|
+
runTurn: oc.input(RunnerTurnSchema).output(eventIterator(AgentEventSchema)),
|
|
29
|
+
// Stop the running turn; the parent's stop button reaching through.
|
|
30
|
+
interrupt: oc.input(RunnerTurnSchema.pick({ conversationId: true })).output(OkSchema),
|
|
31
|
+
// Liveness, driven by the parent: keepalive and gone-detection in one, the host hub's heartbeat verbatim.
|
|
32
|
+
ping: oc.output(OkSchema),
|
|
33
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { documentOf, documentTitle, isDocumentPath, isPlanDocumentPath, PLAN_DOCUMENTS_DIR } from "./documents.js";
|
|
3
|
+
|
|
4
|
+
const write = (path: string, newText: string, extra: { oldText?: string; truncated?: boolean } = {}) => [
|
|
5
|
+
{ type: "diff" as const, path, newText, ...extra },
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
describe("documentOf", () => {
|
|
9
|
+
test("a markdown file written whole is a document, titled by its opening heading", () => {
|
|
10
|
+
expect(documentOf("Write", write("docs/findings.md", "# Why it is slow\n\nBecause of the poll."))).toEqual({
|
|
11
|
+
path: "docs/findings.md",
|
|
12
|
+
title: "Why it is slow",
|
|
13
|
+
markdown: "# Why it is slow\n\nBecause of the poll.",
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("a plan file says so, which is what gives it the plan card's face", () => {
|
|
18
|
+
const document = documentOf("Write", write(`${PLAN_DOCUMENTS_DIR}/wiggly-spring.md`, "## The plan\n\nStep one."));
|
|
19
|
+
expect(document?.plan).toBe(true);
|
|
20
|
+
expect(document?.title).toBe("The plan");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("carries the wire cap forward, so a clipped document can say it is clipped", () => {
|
|
24
|
+
expect(documentOf("Write", write("notes.md", "# Notes", { truncated: true }))?.truncated).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("an EDIT to a document is not one: its newText is a fragment, and the change is what a reader wants", () => {
|
|
28
|
+
expect(documentOf("Edit", write("docs/findings.md", "a replaced paragraph", { oldText: "the old paragraph" }))).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("code is not a document, whatever it was written with", () => {
|
|
32
|
+
expect(documentOf("Write", write("src/foo.ts", "export const x = 1;"))).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a call with no diff has written nothing to show", () => {
|
|
36
|
+
expect(documentOf("Write", [{ type: "text", text: "File created successfully." }])).toBeUndefined();
|
|
37
|
+
expect(documentOf("Write", undefined)).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("names arrive in whatever case a backend spells them", () => {
|
|
41
|
+
expect(documentOf("write", write("notes.md", "# Notes"))?.path).toBe("notes.md");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("documentTitle", () => {
|
|
46
|
+
test("falls back to the file name when the document opens with prose", () => {
|
|
47
|
+
expect(documentTitle("No heading here, just prose.", "docs/some-notes.md")).toBe("some-notes.md");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("takes the heading whatever its depth", () => {
|
|
51
|
+
expect(documentTitle("###### deep\n", "notes.md")).toBe("deep");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("paths", () => {
|
|
56
|
+
test("markdown, by either extension, in any case", () => {
|
|
57
|
+
expect(isDocumentPath("a/b.md")).toBe(true);
|
|
58
|
+
expect(isDocumentPath("a/b.MARKDOWN")).toBe(true);
|
|
59
|
+
expect(isDocumentPath("a/b.txt")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a plan is one by where it lives, not by what it is called", () => {
|
|
63
|
+
expect(isPlanDocumentPath(`${PLAN_DOCUMENTS_DIR}/anything.md`)).toBe(true);
|
|
64
|
+
expect(isPlanDocumentPath("docs/plans/anything.md")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/documents.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { STATE_DIR } from "@intentic/constants";
|
|
2
|
+
import type { CardDocument, ToolCallContent } from "./events.js";
|
|
3
|
+
import { planParts } from "./title.js";
|
|
4
|
+
|
|
5
|
+
/* A DOCUMENT A TURN WROTE FOR A PERSON TO READ, told apart from the files it changed for the machine.
|
|
6
|
+
*
|
|
7
|
+
* A transcript renders ACTS: a file was written, a command ran, a page was fetched. That is the right shape for
|
|
8
|
+
* nearly everything an agent does, and the wrong shape for the one thing it produces that is addressed to the
|
|
9
|
+
* reader. An analysis, a findings write-up, a plan: the card for those said `Write · +135 −0` and folded, so the
|
|
10
|
+
* document the next question was ABOUT was the one thing the chat would not show. Worse, the model reaches for a
|
|
11
|
+
* file precisely BECAUSE prose in the answer is expensive, so the better it behaves the less the reader sees.
|
|
12
|
+
*
|
|
13
|
+
* The rules live here, in the contract, because both sides run them and they must not drift: the daemon reads
|
|
14
|
+
* them to decide what a parked card is about (agent.ts), the browser to decide how a tool card draws
|
|
15
|
+
* (toolPresentation.ts). Two copies would let a card render a document the question card had never heard of.
|
|
16
|
+
*
|
|
17
|
+
* ONLY A WHOLE-FILE WRITE COUNTS. An Edit's `newText` is the replacement fragment, not the document: rendered as
|
|
18
|
+
* prose it is a mid-sentence slice with no heading, and the DIFF is what a reader wants from an edit anyway. So
|
|
19
|
+
* a Write of a markdown file is a document, and an edit to one stays a diff. */
|
|
20
|
+
|
|
21
|
+
/* WHERE THE CLI'S PLAN FILES LAND, workspace-relative.
|
|
22
|
+
*
|
|
23
|
+
* `~/.claude/plans` is a symlink onto this directory (sessions/session-store.ts links the SDK's conversation
|
|
24
|
+
* state onto the workspace volume), which makes a plan file the one document with a HARNESS-OWNED address: no
|
|
25
|
+
* guessing whether prose is a plan, no threshold on length, the path says so. That is why plan documents get
|
|
26
|
+
* their own treatment while everything else rides the general markdown rule above. */
|
|
27
|
+
export const PLAN_DOCUMENTS_DIR = `${STATE_DIR}/records/sessions/claude/plans`;
|
|
28
|
+
|
|
29
|
+
// Prose, by extension. Deliberately short: a document is something the chat can RENDER, and markdown is what
|
|
30
|
+
// the chat's prose pipeline speaks. A `.txt` report would render as an unstyled wall and reads better as the
|
|
31
|
+
// plain text box it already gets.
|
|
32
|
+
const DOCUMENT_EXTENSIONS = [".md", ".markdown"];
|
|
33
|
+
|
|
34
|
+
export const isDocumentPath = (path: string): boolean => {
|
|
35
|
+
const lower = path.toLowerCase();
|
|
36
|
+
return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Whether a path is one of the CLI's plan files. Matched on the directory rather than on the name, which is a
|
|
40
|
+
// mint-fresh three-word phrase (`map-of-this-wiggly-spring.md`) carrying no signal at all.
|
|
41
|
+
export const isPlanDocumentPath = (path: string): boolean => path.startsWith(`${PLAN_DOCUMENTS_DIR}/`);
|
|
42
|
+
|
|
43
|
+
/* What a document is CALLED. Its opening heading when it has one, which is the line the author wrote to name
|
|
44
|
+
* the whole thing (planParts, the same split the plan card titles from), and its file name when it does not.
|
|
45
|
+
*
|
|
46
|
+
* Never the path: a card that says `.intentic/records/sessions/claude/plans/map-of-this-wiggly-spring.md`
|
|
47
|
+
* has told the reader nothing about what is in it, and that string is the CLI's mint, not anybody's title. */
|
|
48
|
+
export const documentTitle = (markdown: string, path: string): string => planParts(markdown).title ?? path.split("/").pop() ?? path;
|
|
49
|
+
|
|
50
|
+
/* The document a tool call produced, or undefined when it produced none, the ONE test both sides ask.
|
|
51
|
+
*
|
|
52
|
+
* Reads the call's structured diff rather than its result text, because the content is already there: a Write's
|
|
53
|
+
* whole file rides the tool_call frame as `newText` (capped at the wire limit, which `truncated` reports), so
|
|
54
|
+
* nothing has to be re-read from disk to draw it, and a published transcript with no workspace behind it draws
|
|
55
|
+
* exactly the same document. */
|
|
56
|
+
export const documentOf = (name: string, content: readonly ToolCallContent[] | undefined): CardDocument | undefined => {
|
|
57
|
+
if (name.toLowerCase() !== "write") {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const diff = content?.find((entry) => entry.type === "diff" && isDocumentPath(entry.path));
|
|
61
|
+
if (diff === undefined || diff.type !== "diff") {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
path: diff.path,
|
|
66
|
+
title: documentTitle(diff.newText, diff.path),
|
|
67
|
+
markdown: diff.newText,
|
|
68
|
+
...(diff.truncated === true ? { truncated: true } : {}),
|
|
69
|
+
...(isPlanDocumentPath(diff.path) ? { plan: true } : {}),
|
|
70
|
+
};
|
|
71
|
+
};
|
package/src/events.ts
CHANGED
|
@@ -434,6 +434,30 @@ export const SharePayloadSchema = z.object({
|
|
|
434
434
|
});
|
|
435
435
|
export type SharePayload = z.infer<typeof SharePayloadSchema>;
|
|
436
436
|
|
|
437
|
+
/* WHAT A PARKED CARD IS ABOUT: the document the turn wrote and is now asking a question against.
|
|
438
|
+
*
|
|
439
|
+
* A card asks for a decision; until this it carried no SUBJECT. The commonest shape of a real decision is "I
|
|
440
|
+
* analysed this and wrote it up, now choose", and the write-up went into a file whose card had already folded
|
|
441
|
+
* itself into `Write · +135 −0` twenty tool calls back. So the reader was asked to choose between options
|
|
442
|
+
* describing a document the chat had never shown them.
|
|
443
|
+
*
|
|
444
|
+
* Carried BY VALUE rather than as a path, for the same reason the diff on a tool call is: the bytes are already
|
|
445
|
+
* in hand when the card is raised, a path would make the card's meaning depend on a file that keeps changing
|
|
446
|
+
* under it, and a restored or published transcript has no workspace to go read. The path rides along anyway, so
|
|
447
|
+
* a document past the wire cap still has somewhere to send the reader.
|
|
448
|
+
*
|
|
449
|
+
* Nothing is asked of the MODEL for this. It calls `ask` exactly as before; the daemon knows what the turn
|
|
450
|
+
* wrote, because every write came past it as a frame (documents.ts decides which of them is a document). A
|
|
451
|
+
* harness that can see the answer must not spend prompt on asking the model to repeat it. */
|
|
452
|
+
export const CardDocumentSchema = z.object({
|
|
453
|
+
path: z.string().describe("Where it lives, as a workspace path."),
|
|
454
|
+
title: z.string().describe("What it is called: its opening heading, or its file name."),
|
|
455
|
+
markdown: z.string().describe("The document itself."),
|
|
456
|
+
truncated: z.boolean().optional().describe("It was clipped at the wire cap; the file on disk has more."),
|
|
457
|
+
plan: z.boolean().optional().describe("It is one of the CLI's plan files, written to be approved rather than merely read."),
|
|
458
|
+
});
|
|
459
|
+
export type CardDocument = z.infer<typeof CardDocumentSchema>;
|
|
460
|
+
|
|
437
461
|
/* THE THREE RESTORABLE CARDS, named so the turn journal can hold them verbatim: a parked turn's raised cards
|
|
438
462
|
* are written down beside its prompt (sandbox turn-journal.ts), and a daemon death under the park restores the
|
|
439
463
|
* very same frames instead of ending the turn `interrupted`, the card the user was about to answer survives
|
|
@@ -444,11 +468,15 @@ const PlanCardSchema = z.object({
|
|
|
444
468
|
kind: z.literal("plan").describe("The agent has written a plan and is waiting for a yes."),
|
|
445
469
|
requestId: z.string().describe("What to send back when you answer."),
|
|
446
470
|
text: z.string().describe("The plan itself."),
|
|
471
|
+
// Present when the plan text POINTS at a document instead of being one: the model wrote the real plan to a
|
|
472
|
+
// file and summarised it here. Absent when the text already is the whole plan, which is the ordinary case.
|
|
473
|
+
document: CardDocumentSchema.optional().describe("The write-up this plan refers to, when the plan itself is a pointer to one."),
|
|
447
474
|
});
|
|
448
475
|
const QuestionCardSchema = z.object({
|
|
449
476
|
kind: z.literal("question").describe("The agent has asked you something and is waiting."),
|
|
450
477
|
requestId: z.string().describe("What to send back when you answer."),
|
|
451
478
|
questions: z.array(AskQuestionSchema).describe("What it wants to know."),
|
|
479
|
+
document: CardDocumentSchema.optional().describe("The document this turn wrote and is asking about, so the choice can be read beside it."),
|
|
452
480
|
});
|
|
453
481
|
const PermissionCardSchema = PermissionAskSchema.extend({
|
|
454
482
|
kind: z.literal("permission").describe("The agent wants to use a tool it needs permission for."),
|
|
@@ -493,6 +521,9 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
493
521
|
base: z.string(),
|
|
494
522
|
unenforced: z.boolean().optional(),
|
|
495
523
|
sync: z.object({ commits: z.number(), blocked: z.array(z.string()) }).optional(),
|
|
524
|
+
// The runner this turn executes on, when the conversation is placed remotely (runners/): the
|
|
525
|
+
// transcript's own statement of where the work is happening. Absent ⇒ this sandbox.
|
|
526
|
+
remote: z.string().optional(),
|
|
496
527
|
}),
|
|
497
528
|
// Emitted after a clean isolated turn whose delta auto-landed (or failed to): landed ⇒ the work is now
|
|
498
529
|
// UNCOMMITTED changes in the main tree (the Changes panel is the review); conflicts ⇒ it stayed safely in
|
|
@@ -621,9 +652,10 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
621
652
|
agentType: z.string().optional(),
|
|
622
653
|
description: z.string().optional(),
|
|
623
654
|
model: z.string().optional(),
|
|
655
|
+
// Which provider serves a `spawned` child (SubagentSessionSchema.provider), absent for an SDK
|
|
656
|
+
// subagent, whose provider is its parent's.
|
|
657
|
+
provider: z.string().optional(),
|
|
624
658
|
background: z.boolean().optional(),
|
|
625
|
-
// A delegation's tmux session, the one live view a subagent doesn't have (SubagentSessionSchema).
|
|
626
|
-
terminal: z.string().optional(),
|
|
627
659
|
}),
|
|
628
660
|
z.object({
|
|
629
661
|
kind: z.literal("subagent_update"),
|
|
@@ -913,6 +945,16 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
913
945
|
"context-window-too-small",
|
|
914
946
|
"subscription-required",
|
|
915
947
|
"agent-busy",
|
|
948
|
+
/* THE SANDBOX HAS NO MEMORY LEFT TO RUN THIS TURN, refused before anything was spawned
|
|
949
|
+
* (platform/memory-admission.ts). Its own code because it is the only refusal here that is
|
|
950
|
+
* about the BOX rather than the request: the prompt, the model and the credential are all
|
|
951
|
+
* fine, and the identical request succeeds once something inside frees room, which is the
|
|
952
|
+
* opposite of context-window-too-small next door. Transient without being on a clock, so
|
|
953
|
+
* there is no resetsAt to offer — what changes the outcome is a turn finishing or a session
|
|
954
|
+
* closing, and the message says so. The client HOLDS the words for the same reason
|
|
955
|
+
* context-window-too-small does: they never reached a provider, and losing them to a
|
|
956
|
+
* capacity fact the user did not cause would be ours to answer for. */
|
|
957
|
+
"sandbox-memory-low",
|
|
916
958
|
])
|
|
917
959
|
.optional(),
|
|
918
960
|
// rate_limit only: when the exhausted window reopens (epoch seconds, from the stream's own
|
package/src/history-state.ts
CHANGED
|
@@ -131,5 +131,12 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
131
131
|
// The burn list for setup-time computer pairings. Identity, like sync's beside it, and for a sharper reason:
|
|
132
132
|
// carrying it into another sandbox would mark that sandbox's own fresh pairing as already spent.
|
|
133
133
|
{ path: "host-pair-consumed.json", portability: "identity" },
|
|
134
|
+
// A runner's enrollment names THIS sandbox as its parent (runners/runners-store.ts): in another sandbox the
|
|
135
|
+
// digest would admit a socket whose runner still dials the old parent. Identity, both files, hosts' reasons.
|
|
136
|
+
{ path: "runner-enrollments.json", portability: "identity" },
|
|
137
|
+
{ path: "runner-pair-consumed.json", portability: "identity" },
|
|
138
|
+
// The runner-SIDE half: who this container belongs to and the token its reconnects present
|
|
139
|
+
// (runners/runner-identity.ts). Carried into another box it would dial the parent as this runner.
|
|
140
|
+
{ path: "runner-identity.json", portability: "identity" },
|
|
134
141
|
{ path: "local-cert/", portability: "identity" },
|
|
135
142
|
];
|
package/src/index.ts
CHANGED
|
@@ -59,6 +59,8 @@ export { historyContract } from "./contracts/history.contract.js";
|
|
|
59
59
|
/* Deliberately NOT part of `sandboxContract` below: that map is the daemon's own HTTP surface, and this one is
|
|
60
60
|
* spoken the other way round, over a connected computer's WebSocket, with the MACHINE implementing it. */
|
|
61
61
|
export { hostContract } from "./contracts/host.contract.js";
|
|
62
|
+
// Same inversion, other direction of trust: spoken over a RUNNER's WebSocket, with the runner implementing it.
|
|
63
|
+
export { runnerContract } from "./contracts/runner.contract.js";
|
|
62
64
|
export { intenticContract } from "./contracts/intentic.contract.js";
|
|
63
65
|
export { inventoryContract } from "./contracts/inventory.contract.js";
|
|
64
66
|
export { logsContract } from "./contracts/logs.contract.js";
|
|
@@ -104,8 +106,10 @@ export * from "./agent-run-model.js";
|
|
|
104
106
|
export * from "./capability-env.js";
|
|
105
107
|
export * from "./capability-secrets.js";
|
|
106
108
|
export * from "./conversation-ids.js";
|
|
109
|
+
export * from "./documents.js";
|
|
107
110
|
export * from "./fast-tier.js";
|
|
108
111
|
export * from "./host-protocol.js";
|
|
112
|
+
export * from "./runner-protocol.js";
|
|
109
113
|
export * from "./listener-protocol.js";
|
|
110
114
|
export * from "./hostnames.js";
|
|
111
115
|
export * from "./model-order.js";
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/* THE RUNNER LINK'S EDGES: the handshake on /system/runners/connect, the env a runner boots with, and the
|
|
4
|
+
* placement value a turn request carries. The procedures spoken over the link once it exists live in
|
|
5
|
+
* contracts/runner.contract.ts; the design is docs/remote-runners-plan.md (workspace root).
|
|
6
|
+
*
|
|
7
|
+
* A runner is the sandbox image booted in runner mode: no browser owner, no tunnel, no public name. It dials
|
|
8
|
+
* its PARENT sandbox and executes turns the parent dispatches; the parent keeps owning the conversation. The
|
|
9
|
+
* host link (host-protocol.ts) is the pattern for everything here, one outbound socket that authenticates in
|
|
10
|
+
* its first frame, because a runner sits behind the same NATs and private networks a laptop does. */
|
|
11
|
+
|
|
12
|
+
// The env the runner container boots with: who to dial, and the single-use pairing that gets it enrolled.
|
|
13
|
+
// Named here so `ic runner up`, the Fly provisioner and the daemon's boot detection spell them identically.
|
|
14
|
+
export const RUNNER_PARENT_URL_ENV = "RUNNER_PARENT_URL";
|
|
15
|
+
export const RUNNER_PAIR_TOKEN_ENV = "RUNNER_PAIR_TOKEN";
|
|
16
|
+
|
|
17
|
+
/* The first frame on /system/runners/connect, plain JSON, never oRPC, for host-protocol.ts's reason: a socket
|
|
18
|
+
* must prove whose it is before a typed link attaches, and the proof cannot be a call on a link that does not
|
|
19
|
+
* exist yet. The token rides the FRAME, never the URL (edge logs). */
|
|
20
|
+
export const RunnerHelloSchema = z.object({
|
|
21
|
+
type: z.literal("runner-hello"),
|
|
22
|
+
// The runner's durable token, redeemed once from the pairing over /system/runners/enroll.
|
|
23
|
+
token: z.string(),
|
|
24
|
+
// The daemon build the runner runs, surfaced per runner so an old image is visible rather than
|
|
25
|
+
// mysteriously wrong.
|
|
26
|
+
version: z.string(),
|
|
27
|
+
/* Parity, reported rather than enforced: the parent knows what IT runs (image, channel, overlay hash) and
|
|
28
|
+
* shows a runner that drifted as "outdated" with a rebuild action. An outdated runner still runs turns, a
|
|
29
|
+
* stale toolchain is a fact the user can weigh, where a refusal would strand work. */
|
|
30
|
+
image: z.string(),
|
|
31
|
+
channel: z.string().optional(),
|
|
32
|
+
overlayHash: z.string().optional(),
|
|
33
|
+
});
|
|
34
|
+
export type RunnerHello = z.infer<typeof RunnerHelloSchema>;
|
|
35
|
+
|
|
36
|
+
// The URL a runner dials, given its parent's public URL. One builder, so `ic`, the Fly provisioner and the
|
|
37
|
+
// daemon route cannot disagree about where the door is (hostConnectUrl's rule).
|
|
38
|
+
export const runnerConnectUrl = (parentUrl: string): string => `${parentUrl.replace(/^http/, "ws").replace(/\/$/, "")}/system/runners/connect`;
|
|
39
|
+
|
|
40
|
+
// Where a runner redeems its pairing for the durable token, once, over plain HTTPS.
|
|
41
|
+
export const runnerEnrollUrl = (parentUrl: string): string => `${parentUrl.replace(/\/$/, "")}/system/runners/enroll`;
|
|
42
|
+
|
|
43
|
+
/* The parent's git door for ONE repository: stock `git fetch`/`git push` against the smart-HTTP pair the
|
|
44
|
+
* parent serves from its real git dirs (<historyRoot>/gits/<encoded id>), authenticated by the runner's own
|
|
45
|
+
* token as a bearer. One builder because three parties spell it: the parent's route, the runner's sync, and
|
|
46
|
+
* anyone debugging with a hand-typed clone. */
|
|
47
|
+
export const runnerGitUrl = (parentUrl: string, repo: string): string => `${parentUrl.replace(/\/$/, "")}/system/runners/git/${encodeURIComponent(repo)}`;
|
|
48
|
+
|
|
49
|
+
/* Where a runner's push LANDS in the parent's git dirs. Never refs/heads/agent/<id> directly: that branch is
|
|
50
|
+
* checked out in the parent's mirror worktree, and git itself refuses updating a checked-out ref, which is a
|
|
51
|
+
* safety property worth keeping rather than configuring away. The parent moves the branch by hard-resetting
|
|
52
|
+
* the mirror worktree to this ref, which advances the checked-out branch through the door git sanctions. */
|
|
53
|
+
export const runnerIncomingRef = (conversationId: string): string => `refs/runner-incoming/${conversationId}`;
|
|
54
|
+
|
|
55
|
+
/* THE CREDENTIAL DOORS: a runner's turns spend the ORIGIN sandbox's model providers, never accounts of their
|
|
56
|
+
* own. The shape is a service, not a sync: the parent resolves each turn's credential with the same code its
|
|
57
|
+
* local turns use and answers with the least that travels — an access token minted for the turn, or a route.
|
|
58
|
+
* Refresh tokens never leave the parent, which is what closes the rotation race two daemons refreshing one
|
|
59
|
+
* account would otherwise run. Design: docs/remote-runners-plan.md §8 (workspace root). */
|
|
60
|
+
export const runnerCredentialsUrl = (parentUrl: string): string => `${parentUrl.replace(/\/$/, "")}/system/runners/credentials`;
|
|
61
|
+
export const runnerCredentialRefreshUrl = (parentUrl: string): string => `${parentUrl.replace(/\/$/, "")}/system/runners/credentials/refresh`;
|
|
62
|
+
|
|
63
|
+
/* The parent's model translator, re-served to runners: subscription-routed turns (codex/grok/kimi under the
|
|
64
|
+
* Claude Code harness, OpenAI-protocol endpoints, the trial) authenticate against a translator whose auth
|
|
65
|
+
* files live on the PARENT's /history. Rather than syncing those (the same rotation race), the runner's
|
|
66
|
+
* harness dials this authenticated proxy and the parent forwards to its loopback translator. The bearer is
|
|
67
|
+
* the runner's own token. */
|
|
68
|
+
export const runnerTranslatorPath = "/system/runners/translator";
|
|
69
|
+
export const runnerTranslatorUrl = (parentUrl: string): string => `${parentUrl.replace(/\/$/, "")}${runnerTranslatorPath}`;
|
|
70
|
+
|
|
71
|
+
export const RunnerCredentialRequestSchema = z.object({
|
|
72
|
+
// The provider as the turn names it (absent = claude), an open vocabulary: endpoint/<id> included.
|
|
73
|
+
agent: z.string().optional(),
|
|
74
|
+
account: z.string().optional(),
|
|
75
|
+
model: z.string().optional(),
|
|
76
|
+
});
|
|
77
|
+
export type RunnerCredentialRequest = z.infer<typeof RunnerCredentialRequestSchema>;
|
|
78
|
+
|
|
79
|
+
/* What travels back, by kind:
|
|
80
|
+
* oauth — a native-Claude access token minted for this turn; `account` present when it is a
|
|
81
|
+
* stored account (then the refresh door re-mints mid-turn), absent for the parent's
|
|
82
|
+
* container-env fallback, which has nothing to rotate.
|
|
83
|
+
* parent-translator — run against the parent's translator through the proxy above; the runner supplies its
|
|
84
|
+
* own token as the bearer (the parent never echoes a credential it only holds hashed).
|
|
85
|
+
* endpoint — a foreign endpoint the runner can dial directly (an anthropic-protocol Endpoint
|
|
86
|
+
* capability), with the bearer that endpoint wants.
|
|
87
|
+
* A refusal is a value with the same codes local resolution uses, so the composer's connect gates read a
|
|
88
|
+
* remote refusal exactly as a local one. */
|
|
89
|
+
export const RunnerCredentialSchema = z.union([
|
|
90
|
+
z.object({ ok: z.literal(true), kind: z.literal("oauth"), accessToken: z.string(), account: z.string().optional() }),
|
|
91
|
+
z.object({ ok: z.literal(true), kind: z.literal("parent-translator"), model: z.string(), trial: z.boolean().optional() }),
|
|
92
|
+
z.object({
|
|
93
|
+
ok: z.literal(true),
|
|
94
|
+
kind: z.literal("endpoint"),
|
|
95
|
+
baseUrl: z.string(),
|
|
96
|
+
authToken: z.string(),
|
|
97
|
+
model: z.string(),
|
|
98
|
+
trial: z.boolean().optional(),
|
|
99
|
+
}),
|
|
100
|
+
z.object({
|
|
101
|
+
ok: z.literal(false),
|
|
102
|
+
code: z.enum(["subscription-required", "claude-reauth", "trial-unavailable"]).optional(),
|
|
103
|
+
message: z.string(),
|
|
104
|
+
}),
|
|
105
|
+
]);
|
|
106
|
+
export type RunnerCredential = z.infer<typeof RunnerCredentialSchema>;
|
|
107
|
+
|
|
108
|
+
export const RunnerCredentialRefreshRequestSchema = z.object({
|
|
109
|
+
account: z.string().min(1),
|
|
110
|
+
// The token the harness was refused with, so the parent's rotation supersedes exactly that one and a
|
|
111
|
+
// token another turn already rotated is adopted, never re-refreshed (claude-credentials' own rule).
|
|
112
|
+
rejected: z.string().min(1),
|
|
113
|
+
});
|
|
114
|
+
export type RunnerCredentialRefreshRequest = z.infer<typeof RunnerCredentialRefreshRequestSchema>;
|
|
115
|
+
|
|
116
|
+
// `accessToken` absent ⇒ the parent could not re-mint (a revoked account); the harness gives up exactly as a
|
|
117
|
+
// local turn whose refresh returned nothing does.
|
|
118
|
+
export const RunnerCredentialRefreshSchema = z.object({ accessToken: z.string().optional() });
|
|
119
|
+
export type RunnerCredentialRefresh = z.infer<typeof RunnerCredentialRefreshSchema>;
|
|
120
|
+
|
|
121
|
+
// What a runner is, hardware-wise: what the placement picker shows and what a future scheduler weighs.
|
|
122
|
+
export const RunnerFactsSchema = z.object({
|
|
123
|
+
cpus: z.number().int().positive(),
|
|
124
|
+
memoryMb: z.number().int().positive(),
|
|
125
|
+
freeDiskMb: z.number().int().nonnegative(),
|
|
126
|
+
// 0..1 of the last minute, the coarse "is it busy" a picker needs, not a metrics feed.
|
|
127
|
+
load: z.number().nonnegative(),
|
|
128
|
+
});
|
|
129
|
+
export type RunnerFacts = z.infer<typeof RunnerFactsSchema>;
|
|
130
|
+
|
|
131
|
+
// One runner as the owner's views list it: enrolled state plus whatever the hub knows right now, the
|
|
132
|
+
// HostSummary shape retold for a runner (no platform/scopes, parity instead).
|
|
133
|
+
export const RunnerSummarySchema = z.object({
|
|
134
|
+
id: z.string(),
|
|
135
|
+
online: z.boolean(),
|
|
136
|
+
version: z.string().optional(),
|
|
137
|
+
image: z.string().optional(),
|
|
138
|
+
channel: z.string().optional(),
|
|
139
|
+
overlayHash: z.string().optional(),
|
|
140
|
+
facts: RunnerFactsSchema.optional(),
|
|
141
|
+
lastSeen: z.number().optional(),
|
|
142
|
+
});
|
|
143
|
+
export type RunnerSummary = z.infer<typeof RunnerSummarySchema>;
|
|
144
|
+
|
|
145
|
+
/* A workspace sync, narrated as it happens: a first contact clones whole repositories, and a person may be
|
|
146
|
+
* watching the "preparing runner" state, so the lines travel while they are produced (runSandboxFlow's
|
|
147
|
+
* argument). `op` says which direction: `pull` brings the runner's checkout of the conversation's branch (and
|
|
148
|
+
* each repo's main line) up to date before a turn, `push` returns the branch after one.
|
|
149
|
+
*
|
|
150
|
+
* `repos` is the conversation's composition as the parent recorded it, because only the parent can know it:
|
|
151
|
+
* the runner's own discovery would see whatever its mirror held from LAST time, and a repo added to the
|
|
152
|
+
* workspace since would silently fall out of the conversation. Each entry names the repo id (the git-door
|
|
153
|
+
* address), the workspace-relative dir the checkout lives at, and the repo's own main branch name. */
|
|
154
|
+
export const RunnerSyncSchema = z.object({
|
|
155
|
+
op: z.enum(["pull", "push"]),
|
|
156
|
+
conversationId: z.string().min(1),
|
|
157
|
+
branch: z.string().min(1),
|
|
158
|
+
repos: z.array(
|
|
159
|
+
z.object({
|
|
160
|
+
repo: z.string().min(1),
|
|
161
|
+
// "" for the workspace root itself; every other repo sits at its root-relative dir.
|
|
162
|
+
dir: z.string(),
|
|
163
|
+
mainBranch: z.string().min(1),
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
166
|
+
});
|
|
167
|
+
export type RunnerSync = z.infer<typeof RunnerSyncSchema>;
|
|
168
|
+
|
|
169
|
+
export const RunnerSyncLineSchema = z.union([
|
|
170
|
+
z.object({ kind: z.literal("line"), text: z.string() }),
|
|
171
|
+
z.object({ kind: z.literal("done"), ok: z.boolean(), detail: z.string().optional() }),
|
|
172
|
+
]);
|
|
173
|
+
export type RunnerSyncLine = z.infer<typeof RunnerSyncLineSchema>;
|
|
174
|
+
|
|
175
|
+
/* One turn, as dispatched. Deliberately NOT AgentTurnSchema: that schema is the browser's request to the
|
|
176
|
+
* parent, full of fields the parent resolves before anything executes (persona, account, title, forks). What
|
|
177
|
+
* crosses the link is the residue after resolution, what the runner's harness spawn actually needs, plus the
|
|
178
|
+
* attachments inline, because the runner has no route onto the parent's attachment store. */
|
|
179
|
+
export const RunnerTurnSchema = z.object({
|
|
180
|
+
conversationId: z.string().min(1),
|
|
181
|
+
branch: z.string().min(1),
|
|
182
|
+
prompt: z.string(),
|
|
183
|
+
provider: z.string(),
|
|
184
|
+
harness: z.string(),
|
|
185
|
+
model: z.string().optional(),
|
|
186
|
+
effort: z.string().optional(),
|
|
187
|
+
thinking: z.boolean().optional(),
|
|
188
|
+
fast: z.boolean().optional(),
|
|
189
|
+
// Which of the ORIGIN sandbox's connected accounts pays for the turn: the runner resolves credentials
|
|
190
|
+
// against the parent (the credential doors below), so this names an account THERE.
|
|
191
|
+
account: z.string().optional(),
|
|
192
|
+
sessionId: z.string().optional(),
|
|
193
|
+
// Base64 because frames are JSON: small by policy (the request schema already caps attachment count), and
|
|
194
|
+
// a turn's attachments are the one payload with no git road to travel. `path` is the workspace-relative
|
|
195
|
+
// path the prompt already names; the runner writes the bytes there so the words and the file agree.
|
|
196
|
+
attachments: z.array(z.object({ path: z.string().min(1), bytesBase64: z.string() })).optional(),
|
|
197
|
+
});
|
|
198
|
+
export type RunnerTurn = z.infer<typeof RunnerTurnSchema>;
|
|
199
|
+
|
|
200
|
+
/* WHERE A CONVERSATION RUNS, decided on its first turn and owned by the conversation from then on, exactly as
|
|
201
|
+
* `isolated` placement already works (agent.routes.ts): later turns follow the registry entry, not whichever
|
|
202
|
+
* client sends them. `runner` implies isolation, a remote conversation is always branch-anchored, because its
|
|
203
|
+
* branch is the unit that moves between machines. Absent means local, which is why this is optional
|
|
204
|
+
* everywhere: the default costs nothing to anyone who never uses it. */
|
|
205
|
+
export const AgentPlacementSchema = z.union([
|
|
206
|
+
z.object({ kind: z.literal("local") }),
|
|
207
|
+
z.object({ kind: z.literal("runner"), id: z.string().min(1) }),
|
|
208
|
+
]);
|
|
209
|
+
export type AgentPlacement = z.infer<typeof AgentPlacementSchema>;
|