@intentic/sandbox-contract 1.300.0 → 1.301.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 (88) hide show
  1. package/dist/contracts/agent.contract.d.ts +774 -1
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/agent.contract.js +3 -2
  4. package/dist/contracts/agent.contract.js.map +1 -1
  5. package/dist/contracts/agents.contract.d.ts +7 -0
  6. package/dist/contracts/agents.contract.d.ts.map +1 -1
  7. package/dist/contracts/agents.contract.js +11 -2
  8. package/dist/contracts/agents.contract.js.map +1 -1
  9. package/dist/contracts/capabilities.contract.d.ts +5 -1
  10. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  11. package/dist/contracts/capabilities.contract.js +3 -2
  12. package/dist/contracts/capabilities.contract.js.map +1 -1
  13. package/dist/contracts/exit.contract.d.ts +15 -3
  14. package/dist/contracts/exit.contract.d.ts.map +1 -1
  15. package/dist/contracts/exit.contract.js +5 -4
  16. package/dist/contracts/exit.contract.js.map +1 -1
  17. package/dist/contracts/host.contract.d.ts +24 -2
  18. package/dist/contracts/host.contract.d.ts.map +1 -1
  19. package/dist/contracts/host.contract.js +4 -3
  20. package/dist/contracts/host.contract.js.map +1 -1
  21. package/dist/contracts/intentic.contract.d.ts +10 -2
  22. package/dist/contracts/intentic.contract.d.ts.map +1 -1
  23. package/dist/contracts/intentic.contract.js +4 -3
  24. package/dist/contracts/intentic.contract.js.map +1 -1
  25. package/dist/contracts/netdisk.contract.d.ts +5 -1
  26. package/dist/contracts/netdisk.contract.d.ts.map +1 -1
  27. package/dist/contracts/netdisk.contract.js +3 -2
  28. package/dist/contracts/netdisk.contract.js.map +1 -1
  29. package/dist/contracts/runner.contract.d.ts +505 -2
  30. package/dist/contracts/runner.contract.d.ts.map +1 -1
  31. package/dist/contracts/runner.contract.js +4 -3
  32. package/dist/contracts/runner.contract.js.map +1 -1
  33. package/dist/contracts/sessions.contract.d.ts +1 -0
  34. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  35. package/dist/contracts/system.contract.d.ts +334 -3
  36. package/dist/contracts/system.contract.d.ts.map +1 -1
  37. package/dist/contracts/system.contract.js +5 -4
  38. package/dist/contracts/system.contract.js.map +1 -1
  39. package/dist/contracts/vpn.contract.d.ts +5 -1
  40. package/dist/contracts/vpn.contract.d.ts.map +1 -1
  41. package/dist/contracts/vpn.contract.js +3 -2
  42. package/dist/contracts/vpn.contract.js.map +1 -1
  43. package/dist/events/agent-events.d.ts +3 -0
  44. package/dist/events/agent-events.d.ts.map +1 -1
  45. package/dist/events/transcript.d.ts +10 -0
  46. package/dist/events/transcript.d.ts.map +1 -1
  47. package/dist/events/transcript.js +13 -0
  48. package/dist/events/transcript.js.map +1 -1
  49. package/dist/index.d.ts +1156 -12
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/protocol/routes.d.ts +6 -0
  52. package/dist/protocol/routes.d.ts.map +1 -1
  53. package/dist/protocol/routes.js +12 -4
  54. package/dist/protocol/routes.js.map +1 -1
  55. package/dist/schemas/agents.d.ts +4 -0
  56. package/dist/schemas/agents.d.ts.map +1 -1
  57. package/dist/schemas/agents.js +3 -0
  58. package/dist/schemas/agents.js.map +1 -1
  59. package/dist/schemas/devices.d.ts +1 -0
  60. package/dist/schemas/devices.d.ts.map +1 -1
  61. package/dist/schemas/devices.js +43 -24
  62. package/dist/schemas/devices.js.map +1 -1
  63. package/dist/state/history-state.d.ts.map +1 -1
  64. package/dist/state/history-state.js +1 -0
  65. package/dist/state/history-state.js.map +1 -1
  66. package/dist/state/runtime-state.d.ts +3 -0
  67. package/dist/state/runtime-state.d.ts.map +1 -1
  68. package/dist/state/runtime-state.js +1 -0
  69. package/dist/state/runtime-state.js.map +1 -1
  70. package/package.json +5 -5
  71. package/src/contracts/agent.contract.ts +3 -2
  72. package/src/contracts/agents.contract.ts +12 -1
  73. package/src/contracts/capabilities.contract.ts +3 -2
  74. package/src/contracts/exit.contract.ts +5 -4
  75. package/src/contracts/host.contract.ts +4 -3
  76. package/src/contracts/intentic.contract.ts +4 -3
  77. package/src/contracts/netdisk.contract.ts +3 -2
  78. package/src/contracts/runner.contract.ts +4 -3
  79. package/src/contracts/system.contract.ts +5 -4
  80. package/src/contracts/vpn.contract.ts +3 -2
  81. package/src/events/transcript.ts +25 -0
  82. package/src/protocol/routes.test.ts +51 -18
  83. package/src/protocol/routes.ts +26 -6
  84. package/src/schemas/agents.ts +6 -0
  85. package/src/schemas/devices.ts +75 -29
  86. package/src/state/history-state.ts +3 -0
  87. package/src/state/runtime-state.test.ts +5 -0
  88. package/src/state/runtime-state.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentic/sandbox-contract",
3
- "version": "1.300.0",
3
+ "version": "1.301.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",
@@ -207,10 +207,10 @@
207
207
  }
208
208
  },
209
209
  "dependencies": {
210
- "@intentic/base": "1.300.0",
211
- "@intentic/constants": "1.300.0",
212
- "@intentic/extension-manifest": "1.300.0",
213
- "@intentic/registry": "1.300.0",
210
+ "@intentic/base": "1.301.0",
211
+ "@intentic/constants": "1.301.0",
212
+ "@intentic/extension-manifest": "1.301.0",
213
+ "@intentic/registry": "1.301.0",
214
214
  "@orpc/contract": "1.14.13",
215
215
  "tslib": "2.8.1",
216
216
  "zod": "4.5.4"
@@ -1,4 +1,5 @@
1
- import { eventIterator, oc } from "@orpc/contract";
1
+ import { oc } from "@orpc/contract";
2
+ import { streamOf } from "../protocol/routes.js";
2
3
  import { AgentCommandsQuerySchema, AgentCommandsSchema } from "../events/cards.js";
3
4
  import { AttachFrameSchema } from "../events/agent-events.js";
4
5
  import { AgentTurnSchema, AttachTurnSchema, StartedTurnSchema } from "../schemas/agent.js";
@@ -30,7 +31,7 @@ export const agentContract = {
30
31
  "Streams everything the agent does: its words, the tools it reaches for, and the answers it gets. Give it the point you have already seen and it replays from there before going live, so a reload loses nothing. The window that started the turn holds no special claim, and any number of watchers on any number of devices see the same thing.",
31
32
  })
32
33
  .input(AttachTurnSchema)
33
- .output(eventIterator(AttachFrameSchema)),
34
+ .output(streamOf(AttachFrameSchema)),
34
35
  reply: oc
35
36
  .route({
36
37
  method: "POST",
@@ -1,11 +1,12 @@
1
1
  import { oc } from "@orpc/contract";
2
- import { AgentTranscriptSchema } from "../events/transcript.js";
2
+ import { AgentToolChildrenSchema, AgentTranscriptSchema } from "../events/transcript.js";
3
3
  import {
4
4
  AgentArchiveSchema,
5
5
  AgentAssignSchema,
6
6
  AgentAutoLandSchema,
7
7
  AgentFileDiffQuerySchema,
8
8
  AgentIdSchema,
9
+ AgentToolChildrenQuerySchema,
9
10
  AgentTranscriptQuerySchema,
10
11
  AgentIdsSchema,
11
12
  AgentLandSchema,
@@ -81,6 +82,16 @@ export const agentsContract = {
81
82
  })
82
83
  .input(AgentTranscriptQuerySchema)
83
84
  .output(AgentTranscriptSchema),
85
+ toolChildren: oc
86
+ .route({
87
+ method: "GET",
88
+ path: "/agents/{id}/transcript/tools/{toolId}",
89
+ summary: "One delegation's own calls",
90
+ description:
91
+ "The calls a delegated agent made under one tool card. A transcript page leaves them behind and reports their count as `nested`, since a settled delegation draws collapsed; this is what fills the card in when it is opened. Empty when the record no longer holds that call.",
92
+ })
93
+ .input(AgentToolChildrenQuerySchema)
94
+ .output(AgentToolChildrenSchema),
84
95
  // Also forgets the provider session, rewind-style, so the next fresh session reads the placed line as the agent's
85
96
  // own.
86
97
  place: oc
@@ -1,4 +1,5 @@
1
- import { eventIterator, oc } from "@orpc/contract";
1
+ import { oc } from "@orpc/contract";
2
+ import { streamOf } from "../protocol/routes.js";
2
3
  import { IntenticLineSchema } from "../events/system-events.js";
3
4
  import {
4
5
  CapabilitiesListSchema,
@@ -38,7 +39,7 @@ export const capabilitiesContract = {
38
39
  "Writes a connection and streams the work of applying it, because some kinds provision real infrastructure and take a while. Sending an id that already exists edits that connection: this is the edit as well as the create. Since a caller is never shown stored credentials, it marks the ones it is leaving alone and the daemon fills them in, which is the only way to change one setting without retyping a key.",
39
40
  })
40
41
  .input(CapabilitySchema)
41
- .output(eventIterator(IntenticLineSchema)),
42
+ .output(streamOf(IntenticLineSchema)),
42
43
  // A credential the caller is keeping arrives as VAULTED here too, so an edit can be tested without retyping a key.
43
44
  probe: oc
44
45
  .route({
@@ -1,4 +1,5 @@
1
- import { eventIterator, oc } from "@orpc/contract";
1
+ import { oc } from "@orpc/contract";
2
+ import { streamOf } from "../protocol/routes.js";
2
3
  import { IntenticLineSchema } from "../events/system-events.js";
3
4
  import { ExitCountriesSchema, ExitIdParamSchema, ExitListSchema, ExitObservationSchema, ExitUseInputSchema } from "../schemas/exit.js";
4
5
  import { OkSchema } from "../schemas/shared.js";
@@ -37,7 +38,7 @@ export const exitContract = {
37
38
  "Starts the exit in the country it was configured for. Streamed, because a first start fetches a catalogue, raises a tunnel and then checks the address, which takes tens of seconds on the free providers and can fail at each step with something worth reading. Starting one that is already up simply says so.",
38
39
  })
39
40
  .input(ExitIdParamSchema)
40
- .output(eventIterator(IntenticLineSchema)),
41
+ .output(streamOf(IntenticLineSchema)),
41
42
  use: oc
42
43
  .route({
43
44
  method: "POST",
@@ -47,7 +48,7 @@ export const exitContract = {
47
48
  "Switches the exit's country, starting it first if it was down. It ends by checking where the world actually sees you and fails if that does not match what you asked for. A switch that quietly left your traffic where it was is the exact failure this whole feature exists to rule out.",
48
49
  })
49
50
  .input(ExitUseInputSchema)
50
- .output(eventIterator(IntenticLineSchema)),
51
+ .output(streamOf(IntenticLineSchema)),
51
52
  // Cheap on tor (a control-port signal); a re-dial to another server for everything else.
52
53
  rotate: oc
53
54
  .route({
@@ -58,7 +59,7 @@ export const exitContract = {
58
59
  "Swaps to another address in the country you are already in. Fails if the address does not actually change, which on a small pool it sometimes cannot.",
59
60
  })
60
61
  .input(ExitIdParamSchema)
61
- .output(eventIterator(IntenticLineSchema)),
62
+ .output(streamOf(IntenticLineSchema)),
62
63
  check: oc
63
64
  .route({
64
65
  method: "POST",
@@ -1,4 +1,5 @@
1
- import { eventIterator, oc } from "@orpc/contract";
1
+ import { oc } from "@orpc/contract";
2
+ import { streamOf } from "../protocol/routes.js";
2
3
  import { z } from "zod";
3
4
  import { DeviceAgentFlowSchema, DeviceFlowLineSchema, DeviceSandboxFlowSchema } from "../schemas/devices.js";
4
5
  import { HostScopesSchema } from "../schemas/capabilities.js";
@@ -18,7 +19,7 @@ export const hostContract = {
18
19
  // One MCP JSON-RPC message forwarded verbatim in both directions, unmodified.
19
20
  mcp: oc.input(z.unknown()).output(z.unknown()),
20
21
  // Streamed for a person watching progress; the scope is checked on the machine, this only adds visibility.
21
- runSandboxFlow: oc.input(DeviceSandboxFlowSchema).output(eventIterator(DeviceFlowLineSchema)),
22
+ runSandboxFlow: oc.input(DeviceSandboxFlowSchema).output(streamOf(DeviceFlowLineSchema)),
22
23
  // Spawns the work detached from the socket, so restarting the agent process cannot brick a swap in progress.
23
- runAgentFlow: oc.input(DeviceAgentFlowSchema).output(eventIterator(DeviceFlowLineSchema)),
24
+ runAgentFlow: oc.input(DeviceAgentFlowSchema).output(streamOf(DeviceFlowLineSchema)),
24
25
  };
@@ -1,4 +1,5 @@
1
- import { eventIterator, oc } from "@orpc/contract";
1
+ import { oc } from "@orpc/contract";
2
+ import { streamOf } from "../protocol/routes.js";
2
3
  import { IntenticLineSchema } from "../events/system-events.js";
3
4
  import { IntenticRunSchema } from "../schemas/intentic.js";
4
5
  import { OkSchema } from "../schemas/shared.js";
@@ -15,7 +16,7 @@ export const intenticContract = {
15
16
  "Runs the sandbox's own command-line tool and streams its output as it arrives, so progress is visible rather than arriving all at once at the end. A failure surfaces once the stream closes.",
16
17
  })
17
18
  .input(IntenticRunSchema)
18
- .output(eventIterator(IntenticLineSchema)),
19
+ .output(streamOf(IntenticLineSchema)),
19
20
  // Launch the minutes-long apply → adopt reconcile as a one-shot tmux job (session panel-infra-apply) and
20
21
  // return immediately, progress is followed by attaching the terminal, not by holding this request open.
21
22
  apply: oc
@@ -39,5 +40,5 @@ export const intenticContract = {
39
40
  description:
40
41
  "The same progress the terminal shows, as structured events, kept on disk so a page refresh does not lose it. It replays from the start of the run and then follows live, closing when the run ends.",
41
42
  })
42
- .output(eventIterator(IntenticLineSchema)),
43
+ .output(streamOf(IntenticLineSchema)),
43
44
  };
@@ -1,4 +1,5 @@
1
- import { eventIterator, oc } from "@orpc/contract";
1
+ import { oc } from "@orpc/contract";
2
+ import { streamOf } from "../protocol/routes.js";
2
3
  import { IntenticLineSchema } from "../events/system-events.js";
3
4
  import { NetdiskIdParamSchema, NetdiskListSchema } from "../schemas/netdisk.js";
4
5
  import { OkSchema } from "../schemas/shared.js";
@@ -28,7 +29,7 @@ export const netdiskContract = {
28
29
  "Mounts a stored disk at its place under /mnt/netdisk, streaming progress. Mounting one that is already mounted simply says so. A read-only disk is mounted read-only; the kernel refuses writes to it.",
29
30
  })
30
31
  .input(NetdiskIdParamSchema)
31
- .output(eventIterator(IntenticLineSchema)),
32
+ .output(streamOf(IntenticLineSchema)),
32
33
  unmount: oc
33
34
  .route({
34
35
  method: "POST",
@@ -1,4 +1,5 @@
1
- import { eventIterator, oc } from "@orpc/contract";
1
+ import { oc } from "@orpc/contract";
2
+ import { streamOf } from "../protocol/routes.js";
2
3
  import { z } from "zod";
3
4
  import { AgentEventSchema } from "../events/agent-events.js";
4
5
  import { RunnerFactsSchema, RunnerSyncLineSchema, RunnerSyncSchema, RunnerTurnSchema } from "../protocol/runner-protocol.js";
@@ -13,9 +14,9 @@ export const runnerContract = {
13
14
  // Hardware facts, refreshed on demand; parity facts (image, overlay hash) ride the hello, not this call.
14
15
  describe: oc.output(RunnerFactsSchema),
15
16
  // `pull` updates the checkout, `push` returns a turn's result; streamed since a first sync clones repos.
16
- syncWorkspace: oc.input(RunnerSyncSchema).output(eventIterator(RunnerSyncLineSchema)),
17
+ syncWorkspace: oc.input(RunnerSyncSchema).output(streamOf(RunnerSyncLineSchema)),
17
18
  // One turn in, the frames a local turn would produce out; the parent republishes them into the local pipeline.
18
- runTurn: oc.input(RunnerTurnSchema).output(eventIterator(AgentEventSchema)),
19
+ runTurn: oc.input(RunnerTurnSchema).output(streamOf(AgentEventSchema)),
19
20
  // Returns `applied` rather than throwing: a missing id is an ordinary race, same as local NOT_FOUND.
20
21
  reply: oc.input(AgentReplySchema).output(z.object({ applied: z.boolean() })),
21
22
  // Attachments/editor context travel uncomposed: the note must build against the runner's own workspace root.
@@ -1,4 +1,5 @@
1
- import { eventIterator, oc } from "@orpc/contract";
1
+ import { oc } from "@orpc/contract";
2
+ import { streamOf } from "../protocol/routes.js";
2
3
  import { z } from "zod";
3
4
  import { SessionTranscriptSchema } from "../events/transcript.js";
4
5
  import { SystemEventSchema } from "../events/system-events.js";
@@ -78,7 +79,7 @@ export const systemContract = {
78
79
  "A stream held open for as long as you want it, carrying heartbeats so a caller notices the sandbox dying at once, batches of file changes so a tree or an editor can refresh itself, and the roster of who else is looking. Give it an id for this connection to appear in that roster; leave it out and you watch without being seen.",
79
80
  })
80
81
  .input(z.object({ clientId: z.string().optional() }))
81
- .output(eventIterator(SystemEventSchema)),
82
+ .output(streamOf(SystemEventSchema)),
82
83
  // A tab's activity self-report (view/session/file/idle), fanned back out to every member on /events.
83
84
  presence: oc
84
85
  .route({
@@ -191,7 +192,7 @@ export const systemContract = {
191
192
  "Start, stop, restart, update, rebuild, roll back, reshape (its memory and CPU caps, privileged, GPU) or remove a sandbox running on a machine you own, relayed over the connection that machine holds open. The answer is a stream because the slowest of these takes minutes, and it is the same stream whichever you ask for. The daemon adds no opinion: the machine enforces its own permissions and a refusal arrives as the last line, in the machine's words, naming the switch to flip.",
192
193
  })
193
194
  .input(DeviceSandboxFlowInputSchema)
194
- .output(eventIterator(DeviceFlowLineSchema)),
195
+ .output(streamOf(DeviceFlowLineSchema)),
195
196
  // Closed set of actions; the daemon builds the command line, not the caller. One sentence is the whole answer.
196
197
  runDeviceCommand: oc
197
198
  .route({
@@ -213,5 +214,5 @@ export const systemContract = {
213
214
  "Updates a machine you own to the current intentic-machine agent, or restarts the loop it is running, over the connection that machine holds open. The answer is a stream of the run's own output — and it normally stops mid-run, because the agent's loop is what carries this connection: the work is detached from it first, so it finishes regardless, and the device's reported version is what confirms it. Takes the machine's \"Run commands\" permission, the same one a command typed there would.",
214
215
  })
215
216
  .input(DeviceAgentFlowInputSchema)
216
- .output(eventIterator(DeviceFlowLineSchema)),
217
+ .output(streamOf(DeviceFlowLineSchema)),
217
218
  };
@@ -1,4 +1,5 @@
1
- import { eventIterator, oc } from "@orpc/contract";
1
+ import { oc } from "@orpc/contract";
2
+ import { streamOf } from "../protocol/routes.js";
2
3
  import { IntenticLineSchema } from "../events/system-events.js";
3
4
  import { OkSchema } from "../schemas/shared.js";
4
5
  import { ForticlientImportInputSchema, ForticlientImportSchema, VpnConnectInputSchema, VpnIdParamSchema, VpnListSchema } from "../schemas/vpn.js";
@@ -28,7 +29,7 @@ export const vpnContract = {
28
29
  "Brings a stored tunnel up, streaming the client's progress as it authenticates and then sets up routing. Streamed because a dial takes seconds and can fail with something you have to read: a wrong password, a gateway certificate nobody trusts, a code it wants. Connecting one that is already up simply says so.",
29
30
  })
30
31
  .input(VpnConnectInputSchema)
31
- .output(eventIterator(IntenticLineSchema)),
32
+ .output(streamOf(IntenticLineSchema)),
32
33
  // Drops a tunnel; tolerates one already down, since the contract is "not up," not "it was up."
33
34
  disconnect: oc
34
35
  .route({
@@ -81,6 +81,14 @@ export const TranscriptToolSchema: z.ZodType<TranscriptTool> = z.lazy(() =>
81
81
  .describe(
82
82
  "Calls a delegated subagent made, nested under the call that started it, so a reopened conversation redraws the delegation rather than collapsing it into one result.",
83
83
  ),
84
+ nested: z
85
+ .number()
86
+ .int()
87
+ .nonnegative()
88
+ .optional()
89
+ .describe(
90
+ "How many calls sit under this one, present in place of `children` when they were left behind. A transcript page does that, since a settled delegation draws collapsed; ask for the call's own children to fill it in.",
91
+ ),
84
92
  thinking: z.string().optional().describe("What the agent was reasoning about around this call."),
85
93
  subagent: TranscriptSubagentSchema.optional().describe(
86
94
  "The helper this call started, as the daemon's registry sees it: what it is, how it is going, what it has spent. What a card can say about a backgrounded child whose result is minutes away.",
@@ -116,6 +124,9 @@ export interface TranscriptTool {
116
124
  locations?: ToolCallLocation[] | undefined;
117
125
  content?: ToolCallContent[] | undefined;
118
126
  children?: TranscriptTool[] | undefined;
127
+ // How many calls sit under this one when `children` is not carried: a transcript page leaves a delegation's run
128
+ // behind, since it only draws once the card is opened.
129
+ nested?: number | undefined;
119
130
  thinking?: string | undefined;
120
131
  subagent?: TranscriptSubagent | undefined;
121
132
  }
@@ -166,6 +177,15 @@ export const TranscriptRowSchema = z.object({
166
177
  "Who said it. A notice is neither side: it is something that happened to the turn, recorded so a reopened conversation can say it. Without those, a turn a provider refused ends on the user's message and reads as broken.",
167
178
  ),
168
179
  text: z.string().describe("The words."),
180
+ // Which run produced this row, carried on every copy of it: the live head's, the record's, a client's mirror.
181
+ // It is how a client says "I am already showing this run" without comparing content, which cannot answer that
182
+ // while the run's last row is still growing.
183
+ run: z
184
+ .string()
185
+ .optional()
186
+ .describe(
187
+ "The run that produced this row. Present on everything a turn produced, absent on rows written outside one. A client draws a run's rows over whatever it already holds for that run, which is what this identifies; content cannot, because the last row of a live run keeps growing.",
188
+ ),
169
189
  // The turn's send time (user rows only), not settlement; the only row-moment the daemon actually knows.
170
190
  sentAt: z
171
191
  .number()
@@ -331,6 +351,11 @@ export const AgentTranscriptSchema = SessionTranscriptSchema.extend({
331
351
  more: z.boolean().describe("Whether older messages precede this page."),
332
352
  });
333
353
 
354
+ // A delegation's own run, fetched when its card opens; empty when the record no longer holds it.
355
+ export const AgentToolChildrenSchema = z.object({
356
+ children: z.array(TranscriptToolSchema).describe("The calls the delegated agent made, in the order it made them."),
357
+ });
358
+
334
359
  // The whole of a published conversation, baked into the page since nothing else may still be running when it opens. The
335
360
  // same TranscriptRow rows the app replays, filtered to the chosen detail, pictures rewritten to published copies.
336
361
  export const SharePayloadSchema = z.object({
@@ -2,7 +2,7 @@ import { eventIterator, oc } from "@orpc/contract";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { z } from "zod";
4
4
  import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTE_SHAPES, SANDBOX_ROUTES, sandboxRouteName } from "../index.js";
5
- import { contractRoutes, routeNameForRequest, routeShapes } from "./routes.js";
5
+ import { contractRoutes, routeNameForRequest, routeShapes, streamOf } from "./routes.js";
6
6
 
7
7
  const fixture = {
8
8
  vpn: {
@@ -111,18 +111,38 @@ describe(`routeShapes`, () => {
111
111
  expect(routeShapes(shaped(z.object({ a: z.string() })))[`vpn.list`]).not.toBe(shapes[`vpn.list`]);
112
112
  });
113
113
 
114
- it(`omits a route whose shape cannot be expressed rather than failing the walk`, () => {
115
- // An oRPC event iterator wraps its output in an opaque type with no schema, so `watch` here keeps its name but
116
- // no shape.
114
+ it(`fingerprints a stream through the frames it declares`, () => {
117
115
  const withStream = {
116
+ vpn: {
117
+ list: fixture.vpn.list,
118
+ watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(streamOf(z.object({ a: z.string() }))),
119
+ },
120
+ };
121
+ expect(Object.keys(routeShapes(withStream)).toSorted()).toEqual([`vpn.list`, `vpn.watch`]);
122
+ // A changed frame is a changed route, which is the whole point of reaching past the iterator.
123
+ const reframed = { vpn: { watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(streamOf(z.object({ a: z.number() }))) } };
124
+ expect(routeShapes(reframed)[`vpn.watch`]).not.toBe(routeShapes(withStream)[`vpn.watch`]);
125
+ });
126
+
127
+ it(`tells a stream of X apart from a route that answers X`, () => {
128
+ const frame = z.object({ a: z.string() });
129
+ const streamed = { vpn: { watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(streamOf(frame)) } };
130
+ const answered = { vpn: { watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(frame) } };
131
+ expect(routeShapes(streamed)[`vpn.watch`]).not.toBe(routeShapes(answered)[`vpn.watch`]);
132
+ });
133
+
134
+ it(`omits a route whose shape cannot be expressed rather than failing the walk`, () => {
135
+ // oRPC's own eventIterator hides its frames, which is why contracts declare streams with streamOf; one that
136
+ // slipped through keeps its name and loses only its shape, instead of breaking the walk for every other route.
137
+ const withRawIterator = {
118
138
  vpn: {
119
139
  list: fixture.vpn.list,
120
140
  watch: oc.route({ method: "GET", path: "/vpn/watch" }).output(eventIterator(z.object({ a: z.string() }))),
121
141
  },
122
142
  };
123
- expect(Object.keys(routeShapes(withStream)).toSorted()).toEqual([`vpn.list`]);
143
+ expect(Object.keys(routeShapes(withRawIterator)).toSorted()).toEqual([`vpn.list`]);
124
144
  expect(
125
- contractRoutes(withStream)
145
+ contractRoutes(withRawIterator)
126
146
  .map((route) => route.name)
127
147
  .toSorted(),
128
148
  ).toEqual([`vpn.list`, `vpn.watch`]);
@@ -130,34 +150,47 @@ describe(`routeShapes`, () => {
130
150
  });
131
151
 
132
152
  describe(`the real sandbox contract`, () => {
133
- it(`fingerprints all but the streaming routes`, () => {
134
- const unshaped = SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES));
135
- // oRPC gives an event iterator's output no schema, so these can't be fingerprinted; named rather than counted
136
- // so a new one added here fails the test.
137
- expect(unshaped.toSorted()).toEqual([
153
+ it(`fingerprints every route, streams included`, () => {
154
+ // Nothing may opt out: a route with no fingerprint is a route the browser can never tell has drifted, and the
155
+ // streaming ones (system.events, agent.attach, the exit dials) carry the frames a stale daemon breaks first.
156
+ expect(SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES))).toEqual([]);
157
+ });
158
+
159
+ it(`fingerprints the streaming routes eventIterator used to hide`, () => {
160
+ expect(
161
+ [
162
+ `agent.attach`,
163
+ `capabilities.add`,
164
+ `exit.rotate`,
165
+ `exit.start`,
166
+ `exit.use`,
167
+ `intentic.applyEvents`,
168
+ `intentic.run`,
169
+ `netdisk.mount`,
170
+ `system.events`,
171
+ `system.manageDeviceSandbox`,
172
+ `system.runDeviceAgentFlow`,
173
+ `vpn.connect`,
174
+ ].filter((name) => name in SANDBOX_ROUTE_SHAPES).toSorted(),
175
+ ).toEqual([
138
176
  `agent.attach`,
139
177
  `capabilities.add`,
140
- // The three exit moves stream since bringing an exit up dials and verifies over tens of seconds, failing at
141
- // any step.
142
178
  `exit.rotate`,
143
179
  `exit.start`,
144
180
  `exit.use`,
145
181
  `intentic.applyEvents`,
146
182
  `intentic.run`,
147
- // A mount dials a file server and can fail at any step with something to read, like a vpn dial.
148
183
  `netdisk.mount`,
149
184
  `system.events`,
150
185
  `system.manageDeviceSandbox`,
151
- // The device agent updates or restarts itself; the stream dies with the process, so lines arrive as they
152
- // happen.
153
186
  `system.runDeviceAgentFlow`,
154
187
  `vpn.connect`,
155
188
  ]);
156
189
  });
157
190
 
158
- it(`fingerprints every other route exactly once`, () => {
191
+ it(`fingerprints every route exactly once`, () => {
159
192
  expect(Object.keys(SANDBOX_ROUTE_SHAPES).every((name) => SANDBOX_ROUTE_NAMES.includes(name))).toBe(true);
160
- expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 12);
193
+ expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length);
161
194
  });
162
195
 
163
196
  it(`derives a route table with no duplicate names`, () => {
@@ -1,9 +1,23 @@
1
+ import { eventIterator } from "@orpc/contract";
1
2
  import { z } from "zod";
2
3
 
3
4
  // Named route surface of the daemon's contract (`<group>.<route>`), derived automatically so nothing here is
4
5
  // hand-maintained. The daemon advertises which routes it implements (the /events hello frame); the browser diffs that
5
6
  // against its own contract so an old daemon's gap is a named feature check, not a silent 404.
6
7
 
8
+ // Every streamed route declares its frames through this, never oRPC's `eventIterator` directly: that returns an opaque
9
+ // standard-schema validator holding no reachable inner schema, so a stream's payload would be unfingerprintable and its
10
+ // drift invisible. The frame schema rides along under a symbol, which oRPC never reads and JSON never serializes.
11
+ // Typed as a plain `symbol`, not the inferred unique one: a unique symbol would ride into every contract's inferred
12
+ // type and break declaration emit on a local name nothing outside can refer to.
13
+ const FRAME: symbol = Symbol.for("intentic.contract.frame");
14
+
15
+ export const streamOf = <T extends z.ZodType>(frame: T) => Object.assign(eventIterator(frame), { [FRAME]: frame });
16
+
17
+ // The frame schema behind a streamed route's validator, or undefined for an ordinary request/response schema.
18
+ const frameOf = (schema: unknown): z.ZodType | undefined =>
19
+ typeof schema === "object" && schema !== null && FRAME in schema ? ((schema as Record<symbol, unknown>)[FRAME] as z.ZodType) : undefined;
20
+
7
21
  // Structural shape of `~orpc.route`, the metadata oRPC attaches to every `oc.route(...)` procedure; read this way since
8
22
  // oRPC's internal types aren't public.
9
23
  interface ContractProcedureLike {
@@ -85,18 +99,24 @@ const fingerprint = (value: unknown): string => {
85
99
  return hash.toString(36);
86
100
  };
87
101
 
88
- // One route's wire shape, or undefined if it can't be expressed (a streaming route with no underlying schema). `io`
89
- // matters: a `.default()` field is optional in and required out, so both directions must be read separately.
102
+ // One side of a route as JSON Schema. A streamed side is read through its frame and tagged, so "answers X" and "streams
103
+ // frames of X" can never fingerprint alike. `io` matters: a `.default()` field is optional in and required out.
104
+ const wireShape = (schema: unknown, io: "input" | "output"): unknown => {
105
+ if (schema === undefined) {
106
+ return undefined;
107
+ }
108
+ const frame = frameOf(schema);
109
+ return frame === undefined ? z.toJSONSchema(schema as z.ZodType, { io }) : { stream: z.toJSONSchema(frame, { io }) };
110
+ };
111
+
112
+ // One route's wire shape, or undefined if it can't be expressed at all.
90
113
  const procedureShape = (value: unknown): string | undefined => {
91
114
  if (typeof value !== "object" || value === null || !("~orpc" in value)) {
92
115
  return undefined;
93
116
  }
94
117
  const { inputSchema, outputSchema } = (value as ContractSchemasLike)["~orpc"];
95
118
  try {
96
- return fingerprint({
97
- in: inputSchema === undefined ? undefined : z.toJSONSchema(inputSchema as z.ZodType, { io: "input" }),
98
- out: outputSchema === undefined ? undefined : z.toJSONSchema(outputSchema as z.ZodType, { io: "output" }),
99
- });
119
+ return fingerprint({ in: wireShape(inputSchema, "input"), out: wireShape(outputSchema, "output") });
100
120
  } catch {
101
121
  return undefined;
102
122
  }
@@ -471,6 +471,12 @@ export const AgentTranscriptQuerySchema = AgentIdSchema.extend({
471
471
  .describe("Return the messages before this position in the record: the `from` of the page below. Absent asks for the most recent turns."),
472
472
  turns: z.coerce.number().int().min(1).max(200).optional().describe("How many of the user's turns to return, newest first. Absent takes the daemon's default."),
473
473
  });
474
+
475
+ // Fills in what a page counted instead of carrying: a delegation's own calls, addressed by the card's id, which is
476
+ // unique within a conversation.
477
+ export const AgentToolChildrenQuerySchema = AgentIdSchema.extend({
478
+ toolId: z.string().min(1).describe("Which tool call, by the id its card carries."),
479
+ });
474
480
  // Absent `ids` archives every archivable finished agent (the lane's "Clear"); unarchive always names its own ids.
475
481
  export const AgentArchiveSchema = z.object({
476
482
  ids: z
@@ -1,6 +1,6 @@
1
1
  // What one of the user's own machines is running.
2
2
  import { z } from "zod";
3
- import { type HostFacts, HostFactsSchema, WslEnvironmentSchema } from "./hosts.js";
3
+ import { hostCardOf, hostEnvironmentOf, type HostFacts, HostFactsSchema, WslEnvironmentSchema } from "./hosts.js";
4
4
  import { DEV_VERSION } from "../state/versions.js";
5
5
  // Desktop-sync report shape shared by the agent, daemon and browser, produced only by `intentic-machine status --json`.
6
6
  // The agent never reports `sandboxes`; the docker half is filled in by whoever reads the report, scoped to the reader's
@@ -451,54 +451,100 @@ export type DevicesList = z.infer<typeof DevicesListSchema>;
451
451
  // The physical computer a device is an environment of. Windows and every WSL distro on it are one machine with one
452
452
  // Docker engine, one screen and one set of disks, each environment holding its own agent and its own door.
453
453
  export interface Machine {
454
- /** A lone device's own key, so its address does not change; a folded machine's is the hostname its doors share. */
454
+ /** The card its doors hang off, else the hostname they share, else a lone uncarded device's own key. */
455
455
  readonly key: string;
456
456
  readonly label: string;
457
- /** Windows first, then distros by label: the side that owns the screen leads. */
457
+ /** Windows first, then distros by the name WSL registered: the side that owns the screen leads. */
458
458
  readonly environments: readonly Device[];
459
459
  }
460
460
 
461
461
  export const deviceHostname = (device: Device): string | undefined => device.facts?.hostname ?? device.report?.hostname;
462
- export const deviceEnvironment = (device: Device): string | undefined => environmentOf(device.facts, device.report);
463
- export const isWslDevice = (device: Device): boolean => deviceEnvironment(device)?.startsWith("wsl:") === true;
464
462
 
465
- // Two native installs that merely share a name stay two machines; only a distro joins the machine whose hostname it
466
- // carries, which is the one fact that makes the name safe to join on.
467
- const byEnvironment = (a: Device, b: Device): number => Number(isWslDevice(a)) - Number(isWslDevice(b)) || a.label.localeCompare(b.label);
463
+ // Which side of a machine a device is. Its door id names the environment it connected under (`<card>::wsl:<distro>`),
464
+ // and that is all an environment nobody has reached since this daemon booted can say about itself: liveness resets on
465
+ // restart, so facts and reports are absent on a side that is merely asleep.
466
+ export const deviceEnvironment = (device: Device): string | undefined =>
467
+ environmentOf(device.facts, device.report) ?? (device.hostId === undefined ? undefined : hostEnvironmentOf(device.hostId));
468
+
469
+ const WSL_PREFIX = "wsl:";
470
+ export const isWslDevice = (device: Device): boolean => deviceEnvironment(device)?.startsWith(WSL_PREFIX) === true;
471
+ // The distro a device runs, by whatever evidence it holds; absent on a native install and on one that has said nothing.
472
+ export const deviceDistro = (device: Device): string | undefined => {
473
+ const environment = deviceEnvironment(device);
474
+ return environment?.startsWith(WSL_PREFIX) === true ? environment.slice(WSL_PREFIX.length) : undefined;
475
+ };
476
+
477
+ // Native first, then distros by the name WSL registered them under rather than by their door id, which is that same
478
+ // name behind a card's and so would order a PC's distros by which card each was connected through.
479
+ const byEnvironment = (a: Device, b: Device): number =>
480
+ Number(isWslDevice(a)) - Number(isWslDevice(b)) || (deviceDistro(a) ?? a.label).localeCompare(deviceDistro(b) ?? b.label);
468
481
 
469
482
  const hostnameKey = (device: Device): string | undefined => deviceHostname(device)?.toLowerCase();
470
483
 
471
- const siblingsOf = (device: Device, devices: readonly Device[]): Device[] => {
472
- const key = hostnameKey(device);
473
- const shared = key === undefined ? [device] : devices.filter((candidate) => hostnameKey(candidate) === key);
474
- return shared.length > 1 && shared.some(isWslDevice) ? shared.toSorted(byEnvironment) : [device];
484
+ // The card a device's door hangs off: one card is one computer, so two doors naming the same card are one machine
485
+ // whatever either has said about itself — which is the whole of what an environment that has never connected says.
486
+ const cardKey = (device: Device): string | undefined => (device.hostId === undefined ? undefined : hostCardOf(device.hostId));
487
+
488
+ // What makes two devices one computer: a shared card, or a shared hostname where a distro answers to it, since WSL
489
+ // hands a distro the Windows machine's name. Two native installs that merely share a name stay two machines.
490
+ const tokensOf = (device: Device, distroNames: ReadonlySet<string>): string[] => {
491
+ const card = cardKey(device);
492
+ const hostname = hostnameKey(device);
493
+ return [
494
+ ...(card === undefined ? [] : [`card:${card.toLowerCase()}`]),
495
+ ...(hostname !== undefined && distroNames.has(hostname) ? [`host:${hostname}`] : []),
496
+ ];
475
497
  };
476
498
 
477
- const machineOf = (device: Device, environments: readonly Device[]): Machine => {
478
- if (environments.length === 1) {
479
- return { key: device.key, label: device.label, environments };
480
- }
481
- // Folding needs a hostname, so it is present here; the fallback only keeps the type honest.
482
- const hostname = deviceHostname(device) ?? device.key;
483
- return { key: hostname, label: hostname, environments };
484
- };
499
+ // Components over both joins at once: a device holding a card token and a hostname token is the evidence that merges
500
+ // the machines those tokens named, so a distro connected under a card of its own still lands on the PC it runs on.
501
+ interface Component {
502
+ readonly tokens: Set<string>;
503
+ readonly devices: Device[];
504
+ }
485
505
 
486
- export const machinesOf = (devices: readonly Device[]): Machine[] => {
487
- const folded = new Set<Device>();
488
- const machines: Machine[] = [];
506
+ const componentsOf = (devices: readonly Device[]): Component[] => {
507
+ const distroNames = new Set(devices.filter(isWslDevice).map(hostnameKey).filter((name) => name !== undefined));
508
+ const components: Component[] = [];
489
509
  for (const device of devices) {
490
- if (folded.has(device)) {
510
+ const tokens = tokensOf(device, distroNames);
511
+ const [head, ...merged] = components.filter((component) => tokens.some((token) => component.tokens.has(token)));
512
+ if (head === undefined) {
513
+ components.push({ tokens: new Set(tokens), devices: [device] });
491
514
  continue;
492
515
  }
493
- const environments = siblingsOf(device, devices);
494
- for (const environment of environments) {
495
- folded.add(environment);
516
+ for (const other of merged) {
517
+ other.tokens.forEach((token) => head.tokens.add(token));
518
+ head.devices.push(...other.devices);
519
+ components.splice(components.indexOf(other), 1);
496
520
  }
497
- machines.push(machineOf(device, environments));
521
+ for (const token of tokens) {
522
+ head.tokens.add(token);
523
+ }
524
+ head.devices.push(device);
498
525
  }
499
- return machines;
526
+ return components;
500
527
  };
501
528
 
529
+ // Addressed by the card of the side that owns the screen, an address that does not change when a second environment
530
+ // connects — unlike a row key, which is a hostname another environment can take first. An uncarded fold has only the
531
+ // hostname its doors share, and an uncarded lone device its own key. Called what the owner calls it: the name the
532
+ // leading environment carries, unless that is nothing but its door id, which reads as a PC named after one of its
533
+ // own sides.
534
+ const machineOf = (environments: readonly Device[]): Machine => {
535
+ const [first, ...rest] = environments;
536
+ // A component holds the device that made it; the empty case only keeps the type honest.
537
+ if (first === undefined) {
538
+ return { key: "", label: "", environments };
539
+ }
540
+ const named = environments.map(cardKey).find((key) => key !== undefined) ?? (rest.length === 0 ? undefined : deviceHostname(first));
541
+ const label = first.label === first.hostId ? (cardKey(first) ?? first.label) : first.label;
542
+ return { key: named ?? first.key, label, environments };
543
+ };
544
+
545
+ export const machinesOf = (devices: readonly Device[]): Machine[] =>
546
+ componentsOf(devices).map((component) => machineOf(component.devices.toSorted(byEnvironment)));
547
+
502
548
  // Every door whose docker reports a given sandbox slug, in list order. More than one is the ordinary case, not a
503
549
  // conflict: Windows and the WSL distros on it share one engine, so each door answers for the same containers.
504
550
  const doorsRunningSandbox = (devices: readonly Device[], slug: string | undefined): Device[] =>