@intentic/sandbox-contract 1.245.0 → 1.247.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 +17 -1
- package/dist/batch-runs.d.ts +2 -0
- package/dist/batch-runs.d.ts.map +1 -1
- package/dist/batch-runs.js +1 -0
- package/dist/batch-runs.js.map +1 -1
- package/dist/command-classes.d.ts +6 -3
- package/dist/command-classes.d.ts.map +1 -1
- package/dist/command-classes.js +43 -18
- package/dist/command-classes.js.map +1 -1
- package/dist/contracts/{cursor.contract.d.ts → accounts.contract.d.ts} +102 -3
- package/dist/contracts/accounts.contract.d.ts.map +1 -0
- package/dist/contracts/accounts.contract.js +61 -0
- package/dist/contracts/accounts.contract.js.map +1 -0
- package/dist/contracts/agent.contract.d.ts +19 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +121 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +4 -4
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/ci.contract.d.ts +2 -0
- package/dist/contracts/ci.contract.d.ts.map +1 -1
- package/dist/contracts/host.contract.d.ts +35 -0
- package/dist/contracts/host.contract.d.ts.map +1 -1
- package/dist/contracts/host.contract.js +3 -2
- package/dist/contracts/host.contract.js.map +1 -1
- package/dist/contracts/personas.contract.d.ts +4 -2
- package/dist/contracts/personas.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +2 -2
- package/dist/contracts/settings.contract.d.ts +58 -20
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +80 -30
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +26 -17
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/usage.contract.d.ts +22 -0
- package/dist/contracts/usage.contract.d.ts.map +1 -1
- package/dist/contracts/usage.contract.js +19 -0
- package/dist/contracts/usage.contract.js.map +1 -1
- package/dist/definition.d.ts +20 -28
- package/dist/definition.d.ts.map +1 -1
- package/dist/documents.d.ts +0 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1 -2
- package/dist/documents.js.map +1 -1
- package/dist/embed.d.ts +23 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +84 -0
- package/dist/embed.js.map +1 -0
- package/dist/events.d.ts +21 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +5 -2
- package/dist/events.js.map +1 -1
- package/dist/fast-tier.js +1 -1
- package/dist/fast-tier.js.map +1 -1
- package/dist/history-state.d.ts.map +1 -1
- package/dist/history-state.js +2 -0
- package/dist/history-state.js.map +1 -1
- package/dist/index.d.ts +453 -306
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -15
- package/dist/index.js.map +1 -1
- package/dist/model-pins.d.ts +17 -0
- package/dist/model-pins.d.ts.map +1 -0
- package/dist/{quick-model.js → model-pins.js} +18 -11
- package/dist/model-pins.js.map +1 -0
- package/dist/model-roles.d.ts +144 -0
- package/dist/model-roles.d.ts.map +1 -0
- package/dist/model-roles.js +129 -0
- package/dist/model-roles.js.map +1 -0
- package/dist/peer-dial.d.ts +33 -0
- package/dist/peer-dial.d.ts.map +1 -0
- package/dist/peer-dial.js +79 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/peer-mcp-server.d.ts +36 -0
- package/dist/peer-mcp-server.d.ts.map +1 -0
- package/dist/peer-mcp-server.js +71 -0
- package/dist/peer-mcp-server.js.map +1 -0
- package/dist/provider-specs.d.ts +38 -20
- package/dist/provider-specs.d.ts.map +1 -1
- package/dist/provider-specs.js +39 -13
- package/dist/provider-specs.js.map +1 -1
- package/dist/runtime-state.d.ts +1 -1
- package/dist/runtime-state.js +1 -1
- package/dist/runtime-state.js.map +1 -1
- package/dist/safety-policy.d.ts +12 -3
- package/dist/safety-policy.d.ts.map +1 -1
- package/dist/safety-policy.js +30 -5
- package/dist/safety-policy.js.map +1 -1
- package/dist/schemas/agent.d.ts +27 -8
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +10 -4
- package/dist/schemas/agent.js.map +1 -1
- package/dist/schemas/agents.d.ts +42 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +25 -4
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +11 -2
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/automations.js +1 -1
- package/dist/schemas/automations.js.map +1 -1
- package/dist/schemas/ci.d.ts +6 -0
- package/dist/schemas/ci.d.ts.map +1 -1
- package/dist/schemas/ci.js +3 -2
- package/dist/schemas/ci.js.map +1 -1
- package/dist/schemas/context.d.ts +30 -0
- package/dist/schemas/context.d.ts.map +1 -0
- package/dist/schemas/context.js +34 -0
- package/dist/schemas/context.js.map +1 -0
- package/dist/schemas/{computers.d.ts → devices.d.ts} +154 -60
- package/dist/schemas/devices.d.ts.map +1 -0
- package/dist/schemas/devices.js +157 -0
- package/dist/schemas/devices.js.map +1 -0
- package/dist/schemas/hosts.d.ts +12 -0
- package/dist/schemas/hosts.d.ts.map +1 -1
- package/dist/schemas/hosts.js +1 -0
- package/dist/schemas/hosts.js.map +1 -1
- package/dist/schemas/issues.d.ts +0 -5
- package/dist/schemas/issues.d.ts.map +1 -1
- package/dist/schemas/issues.js +0 -1
- package/dist/schemas/issues.js.map +1 -1
- package/dist/schemas/personas.d.ts +5 -3
- package/dist/schemas/personas.d.ts.map +1 -1
- package/dist/schemas/personas.js +3 -2
- package/dist/schemas/personas.js.map +1 -1
- package/dist/schemas/plan-limits.d.ts +20 -0
- package/dist/schemas/plan-limits.d.ts.map +1 -1
- package/dist/schemas/plan-limits.js +21 -0
- package/dist/schemas/plan-limits.js.map +1 -1
- package/dist/schemas/provider-oauth.d.ts +48 -16
- package/dist/schemas/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/provider-oauth.js +22 -20
- package/dist/schemas/provider-oauth.js.map +1 -1
- package/dist/schemas/settings.d.ts +42 -16
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +22 -28
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/terminal.js +9 -9
- package/dist/schemas/terminal.js.map +1 -1
- package/dist/schemas/usage.d.ts +5 -2
- package/dist/schemas/usage.d.ts.map +1 -1
- package/dist/schemas/usage.js +5 -2
- package/dist/schemas/usage.js.map +1 -1
- package/dist/shell-regions.d.ts +4 -0
- package/dist/shell-regions.d.ts.map +1 -0
- package/dist/shell-regions.js +156 -0
- package/dist/shell-regions.js.map +1 -0
- package/dist/workspace-state.d.ts +8 -0
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +13 -5
- package/dist/workspace-state.js.map +1 -1
- package/package.json +37 -4
- package/src/agent-catalog.ts +2 -2
- package/src/arrival.ts +3 -3
- package/src/batch-runs.test.ts +10 -5
- package/src/batch-runs.ts +10 -3
- package/src/command-classes.test.ts +195 -71
- package/src/command-classes.ts +148 -46
- package/src/contracts/accounts.contract.ts +94 -0
- package/src/contracts/agents.contract.ts +4 -3
- package/src/contracts/exit.contract.ts +2 -2
- package/src/contracts/host.contract.ts +17 -5
- package/src/contracts/settings.contract.ts +1 -1
- package/src/contracts/system.contract.ts +43 -24
- package/src/contracts/usage.contract.ts +31 -0
- package/src/contracts/vpn.contract.ts +2 -2
- package/src/documents.test.ts +2 -1
- package/src/documents.ts +7 -11
- package/src/embed.test.ts +68 -0
- package/src/embed.ts +164 -0
- package/src/events.ts +31 -4
- package/src/fast-tier.test.ts +1 -1
- package/src/fast-tier.ts +5 -5
- package/src/history-state.ts +12 -3
- package/src/host-protocol.ts +2 -2
- package/src/index.ts +8 -16
- package/src/model-order.ts +1 -1
- package/src/{quick-model.test.ts → model-pins.test.ts} +73 -29
- package/src/model-pins.ts +183 -0
- package/src/model-roles.ts +224 -0
- package/src/peer-dial.test.ts +203 -0
- package/src/peer-dial.ts +163 -0
- package/src/peer-mcp-server.test.ts +104 -0
- package/src/peer-mcp-server.ts +144 -0
- package/src/plan-pools.ts +1 -1
- package/src/prompt-complexity.test.ts +1 -1
- package/src/prompt-complexity.ts +2 -2
- package/src/provider-specs.test.ts +45 -18
- package/src/provider-specs.ts +147 -67
- package/src/routes.test.ts +6 -3
- package/src/runner-protocol.ts +1 -1
- package/src/runtime-state.ts +2 -2
- package/src/safety-policy.test.ts +88 -0
- package/src/safety-policy.ts +84 -14
- package/src/schemas/agent.ts +83 -30
- package/src/schemas/agents.ts +67 -6
- package/src/schemas/automations.ts +6 -4
- package/src/schemas/capabilities.ts +4 -4
- package/src/schemas/ci.ts +23 -6
- package/src/schemas/context.ts +87 -0
- package/src/schemas/{computers.ts → devices.ts} +190 -107
- package/src/schemas/hosts.ts +5 -1
- package/src/schemas/issues.ts +0 -4
- package/src/schemas/personas.ts +8 -3
- package/src/schemas/plan-limits.ts +50 -0
- package/src/schemas/provider-oauth.ts +49 -52
- package/src/schemas/settings.ts +105 -140
- package/src/schemas/terminal.ts +12 -12
- package/src/schemas/usage.ts +62 -27
- package/src/schemas/version-seam.test.ts +0 -1
- package/src/shell-regions.ts +289 -0
- package/src/versions.ts +2 -2
- package/src/webext-links.ts +2 -2
- package/src/webext-protocol.ts +2 -2
- package/src/workspace-state.test.ts +48 -1
- package/src/workspace-state.ts +48 -11
- package/dist/agent-run-model.d.ts +0 -4
- package/dist/agent-run-model.d.ts.map +0 -1
- package/dist/agent-run-model.js +0 -13
- package/dist/agent-run-model.js.map +0 -1
- package/dist/contracts/claude.contract.d.ts +0 -91
- package/dist/contracts/claude.contract.d.ts.map +0 -1
- package/dist/contracts/claude.contract.js +0 -50
- package/dist/contracts/claude.contract.js.map +0 -1
- package/dist/contracts/cursor.contract.d.ts.map +0 -1
- package/dist/contracts/cursor.contract.js +0 -50
- package/dist/contracts/cursor.contract.js.map +0 -1
- package/dist/contracts/grok.contract.d.ts +0 -36
- package/dist/contracts/grok.contract.d.ts.map +0 -1
- package/dist/contracts/grok.contract.js +0 -31
- package/dist/contracts/grok.contract.js.map +0 -1
- package/dist/contracts/keys.contract.d.ts +0 -81
- package/dist/contracts/keys.contract.d.ts.map +0 -1
- package/dist/contracts/keys.contract.js +0 -51
- package/dist/contracts/keys.contract.js.map +0 -1
- package/dist/quick-model.d.ts +0 -15
- package/dist/quick-model.d.ts.map +0 -1
- package/dist/quick-model.js.map +0 -1
- package/dist/schemas/computers.d.ts.map +0 -1
- package/dist/schemas/computers.js +0 -135
- package/dist/schemas/computers.js.map +0 -1
- package/src/agent-run-model.test.ts +0 -76
- package/src/agent-run-model.ts +0 -65
- package/src/contracts/claude.contract.ts +0 -71
- package/src/contracts/cursor.contract.ts +0 -74
- package/src/contracts/grok.contract.ts +0 -41
- package/src/contracts/keys.contract.ts +0 -79
- package/src/quick-model.ts +0 -155
|
@@ -2,11 +2,12 @@ import { eventIterator, oc } from "@orpc/contract";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { SessionTranscriptSchema, SystemEventSchema } from "../events.js";
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
DeviceAgentFlowInputSchema,
|
|
6
|
+
DeviceCommandInputSchema,
|
|
7
|
+
DeviceCommandResultSchema,
|
|
8
|
+
DeviceFlowLineSchema,
|
|
9
|
+
DeviceSandboxFlowInputSchema,
|
|
10
|
+
} from "../schemas/devices.js";
|
|
10
11
|
import { PresenceReportSchema } from "../schemas/logs.js";
|
|
11
12
|
import { OkSchema } from "../schemas/shared.js";
|
|
12
13
|
import { DaemonSessionSchema, InfoSchema, ManifestProblemsSchema } from "../schemas/system.js";
|
|
@@ -160,52 +161,70 @@ export const systemContract = {
|
|
|
160
161
|
.route({
|
|
161
162
|
method: "GET",
|
|
162
163
|
path: "/system/subagents",
|
|
163
|
-
summary: "
|
|
164
|
+
summary: "Subagents the agents have started",
|
|
164
165
|
description:
|
|
165
|
-
"Every
|
|
166
|
+
"Every subagent and child agent this sandbox's conversations have delegated work to, whichever tool started it, with what each one is doing.",
|
|
166
167
|
})
|
|
167
168
|
.output(SubagentsListSchema),
|
|
168
169
|
subagentTranscript: oc
|
|
169
170
|
.route({
|
|
170
171
|
method: "GET",
|
|
171
172
|
path: "/system/subagents/{id}/transcript",
|
|
172
|
-
summary: "A
|
|
173
|
+
summary: "A subagent's record",
|
|
173
174
|
description:
|
|
174
|
-
"The full record of one delegated
|
|
175
|
+
"The full record of one delegated subagent, in the same shape as any other conversation. It comes live from the parent turn while it works, and from stored history once it has finished.",
|
|
175
176
|
})
|
|
176
177
|
.input(SubagentIdParamSchema)
|
|
177
178
|
.output(SessionTranscriptSchema),
|
|
178
|
-
/* Start, stop, restart, update, rebuild, roll back or remove a sandbox on one of the user's own
|
|
179
|
-
* the
|
|
179
|
+
/* Start, stop, restart, update, rebuild, roll back or remove a sandbox on one of the user's own devices,
|
|
180
|
+
* the Devices view's buttons, relayed to the machine over the socket it holds open to us.
|
|
180
181
|
*
|
|
181
182
|
* Streamed because the slowest of these takes minutes, and it is the same stream whatever the op: one door
|
|
182
183
|
* for one decision, so the view has one shape to render rather than one per duration. The daemon adds no
|
|
183
184
|
* judgement, the machine enforces its own switches and its refusal arrives as the terminal `error` line,
|
|
184
185
|
* in its own words, naming the control to flip. */
|
|
185
|
-
|
|
186
|
+
manageDeviceSandbox: oc
|
|
186
187
|
.route({
|
|
187
188
|
method: "POST",
|
|
188
|
-
path: "/system/
|
|
189
|
-
summary: "Drive a sandbox on one of your own
|
|
189
|
+
path: "/system/devices/{id}/sandboxes/{slug}",
|
|
190
|
+
summary: "Drive a sandbox on one of your own devices",
|
|
190
191
|
description:
|
|
191
|
-
"Start, stop, restart, update, rebuild, roll back 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
|
+
"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
|
-
.input(
|
|
194
|
-
.output(eventIterator(
|
|
195
|
-
/* Run one of this product's own CLI actions on a connected
|
|
194
|
+
.input(DeviceSandboxFlowInputSchema)
|
|
195
|
+
.output(eventIterator(DeviceFlowLineSchema)),
|
|
196
|
+
/* Run one of this product's own CLI actions on a connected device, from a button rather than through an
|
|
196
197
|
* agent. A closed set of names, and the daemon builds the command line from the name (see the schema): the
|
|
197
198
|
* browser never sends one, because the socket underneath also carries `run_command`.
|
|
198
199
|
*
|
|
199
200
|
* Not a stream, unlike the sandbox ops beside it: these are seconds-long CLI calls whose whole answer is the
|
|
200
201
|
* sentence they print at the end, and a stream for that is a shape with nothing to put in it. */
|
|
201
|
-
|
|
202
|
+
runDeviceCommand: oc
|
|
202
203
|
.route({
|
|
203
204
|
method: "POST",
|
|
204
|
-
path: "/system/
|
|
205
|
-
summary: "Run one of your
|
|
205
|
+
path: "/system/devices/{id}/commands/{command}",
|
|
206
|
+
summary: "Run one of your device's own CLI actions",
|
|
206
207
|
description:
|
|
207
|
-
"Performs a named action on a machine you own by running its own intentic-machine command there — turning that
|
|
208
|
+
"Performs a named action on a machine you own by running its own intentic-machine command there — turning that device's port mirroring off, say — over the connection it holds open. The set of actions is fixed and the command line is built here from the name, never sent by the caller. The machine enforces its own permissions and a refusal comes back as its own sentence, naming the switch to flip.",
|
|
208
209
|
})
|
|
209
|
-
.input(
|
|
210
|
-
.output(
|
|
210
|
+
.input(DeviceCommandInputSchema)
|
|
211
|
+
.output(DeviceCommandResultSchema),
|
|
212
|
+
/* Update or restart the agent on one of the user's own devices — the remedy the Devices view used to print
|
|
213
|
+
* as a command to go and type, on the view built to replace that terminal.
|
|
214
|
+
*
|
|
215
|
+
* Streamed like the sandbox ops, and for a reason of its own: this is the one call whose transport the work
|
|
216
|
+
* destroys. Both ops stop the resident process holding the socket, so the stream ends WITHOUT a terminal
|
|
217
|
+
* frame in the ordinary, successful case, and callers must read that as "it started" rather than as a
|
|
218
|
+
* failure (DeviceAgentFlowSchema has the whole argument). The confirmation is the device's agent version
|
|
219
|
+
* moving on the next read of the fleet. */
|
|
220
|
+
runDeviceAgentFlow: oc
|
|
221
|
+
.route({
|
|
222
|
+
method: "POST",
|
|
223
|
+
path: "/system/devices/{id}/agent/{op}",
|
|
224
|
+
summary: "Update or restart the agent on one of your own devices",
|
|
225
|
+
description:
|
|
226
|
+
"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.",
|
|
227
|
+
})
|
|
228
|
+
.input(DeviceAgentFlowInputSchema)
|
|
229
|
+
.output(eventIterator(DeviceFlowLineSchema)),
|
|
211
230
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { oc } from "@orpc/contract";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { LimitResetClaimSchema, LimitResetStatusSchema } from "../schemas/plan-limits.js";
|
|
3
4
|
import { DayWindowQuerySchema, UsageRollupSchema } from "../schemas/usage.js";
|
|
4
5
|
|
|
5
6
|
// The one control over the headroom readings a person has: measure again, now, every connection. `force`
|
|
@@ -38,4 +39,34 @@ export const usageContract = {
|
|
|
38
39
|
})
|
|
39
40
|
.input(RefreshPlanLimitsSchema)
|
|
40
41
|
.output(z.object({ ok: z.literal(true) })),
|
|
42
|
+
/* THE WAY PAST A SPENT SESSION WINDOW THAT ISN'T WAITING (LimitResetStatusSchema has what it costs).
|
|
43
|
+
*
|
|
44
|
+
* ASKED, NOT POLLED, and that is the whole reason it is a route of its own rather than another field on the
|
|
45
|
+
* account list. The provider only evaluates it when told the account is at the wall, so asking is a claim
|
|
46
|
+
* about the account's state; asking on every headroom sweep, for every connection, would be that claim made
|
|
47
|
+
* continuously and falsely. It is asked at the one moment it is true — a refused turn, with its strip on
|
|
48
|
+
* screen — and the answer is worth nothing a moment later, so nothing caches it.
|
|
49
|
+
*
|
|
50
|
+
* Answers `available: false` rather than failing for an account with no such mechanism, so a caller can ask
|
|
51
|
+
* about any account it holds without first knowing which provider grants one. */
|
|
52
|
+
limitReset: oc
|
|
53
|
+
.route({
|
|
54
|
+
method: "GET",
|
|
55
|
+
path: "/usage/limit-reset/{account}",
|
|
56
|
+
summary: "Whether this account's session window can be reopened now",
|
|
57
|
+
description:
|
|
58
|
+
"Asks the provider whether it will reopen this account's spent session window immediately, which some plans grant once a week. Only worth asking about an account that has actually been refused: the answer is the provider's judgement at this moment, it is not cached, and an account with no such grant answers plainly that it has none.",
|
|
59
|
+
})
|
|
60
|
+
.input(z.object({ account: z.string().min(1).describe("Which account.") }))
|
|
61
|
+
.output(LimitResetStatusSchema),
|
|
62
|
+
claimLimitReset: oc
|
|
63
|
+
.route({
|
|
64
|
+
method: "POST",
|
|
65
|
+
path: "/usage/limit-reset/{account}/claim",
|
|
66
|
+
summary: "Reopen this account's session window now",
|
|
67
|
+
description:
|
|
68
|
+
"Spends one of the account's weekly resets to reopen its session window immediately. The weekly allowance is untouched and still binds. Answers with what the provider actually did: only `reset` changed anything, and it is the cue to send the refused turn again.",
|
|
69
|
+
})
|
|
70
|
+
.input(z.object({ account: z.string().min(1).describe("Which account.") }))
|
|
71
|
+
.output(LimitResetClaimSchema),
|
|
41
72
|
};
|
|
@@ -5,14 +5,14 @@ import { ForticlientImportInputSchema, ForticlientImportSchema, VpnConnectInputS
|
|
|
5
5
|
|
|
6
6
|
// The live VPN surface. A VPN is ADDED as a `vpn` capability (credentials, autoConnect, capabilities.contract);
|
|
7
7
|
// it is DIALLED here. The split is deliberate: connecting is a runtime operation that both the operator (the
|
|
8
|
-
//
|
|
8
|
+
// VPN capability card) and the agent (`vpn` on its PATH, which calls these very routes) perform many times
|
|
9
9
|
// over one stored connection, so it cannot be a capability re-add, and its result is richer than a
|
|
10
10
|
// CapabilityStatus, which is why `list` returns VpnLinks instead of {state, detail}.
|
|
11
11
|
//
|
|
12
12
|
// Every route reads tunnel state back from the OS rather than from daemon memory, so the agent dropping a
|
|
13
13
|
// tunnel from a shell and the UI dropping it are the same event, and a daemon restart observes the truth.
|
|
14
14
|
export const vpnContract = {
|
|
15
|
-
// Every configured VPN with its live link state, the
|
|
15
|
+
// Every configured VPN with its live link state, the VPN card, the rail indicator, and `vpn list`.
|
|
16
16
|
list: oc
|
|
17
17
|
.route({
|
|
18
18
|
method: "GET",
|
package/src/documents.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import { documentOf, documentTitle, isDocumentPath, isPlanDocumentPath
|
|
2
|
+
import { documentOf, documentTitle, isDocumentPath, isPlanDocumentPath } from "./documents.js";
|
|
3
|
+
import { PLAN_DOCUMENTS_DIR } from "./workspace-state.js";
|
|
3
4
|
|
|
4
5
|
const write = (path: string, newText: string, extra: { oldText?: string; truncated?: boolean } = {}) => [
|
|
5
6
|
{ type: "diff" as const, path, newText, ...extra },
|
package/src/documents.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { STATE_DIR } from "@intentic/constants";
|
|
2
1
|
import type { CardDocument, ToolCallContent } from "./events.js";
|
|
3
2
|
import { planParts } from "./title.js";
|
|
3
|
+
import { PLAN_DOCUMENTS_DIR } from "./workspace-state.js";
|
|
4
4
|
|
|
5
5
|
/* A DOCUMENT A TURN WROTE FOR A PERSON TO READ, told apart from the files it changed for the machine.
|
|
6
6
|
*
|
|
@@ -18,14 +18,6 @@ import { planParts } from "./title.js";
|
|
|
18
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
19
|
* a Write of a markdown file is a document, and an edit to one stays a diff. */
|
|
20
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
21
|
// Prose, by extension. Deliberately short: a document is something the chat can RENDER, and markdown is what
|
|
30
22
|
// the chat's prose pipeline speaks. A `.txt` report would render as an unstyled wall and reads better as the
|
|
31
23
|
// plain text box it already gets.
|
|
@@ -36,8 +28,12 @@ export const isDocumentPath = (path: string): boolean => {
|
|
|
36
28
|
return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
37
29
|
};
|
|
38
30
|
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
/* Whether a path is one of the CLI's plan files, which is what earns a document the plan treatment here rather
|
|
32
|
+
* than the general markdown rule above: the harness owns that address, so there is no guessing whether prose is
|
|
33
|
+
* a plan and no threshold on length. Matched on the DIRECTORY rather than on the name, which is a mint-fresh
|
|
34
|
+
* three-word phrase (`map-of-this-wiggly-spring.md`) carrying no signal at all.
|
|
35
|
+
*
|
|
36
|
+
* The directory itself is declared in workspace-state.ts, beside the lock it is the one exception to. */
|
|
41
37
|
export const isPlanDocumentPath = (path: string): boolean => path.startsWith(`${PLAN_DOCUMENTS_DIR}/`);
|
|
42
38
|
|
|
43
39
|
/* What a document is CALLED. Its opening heading when it has one, which is the line the author wrote to name
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { webcrypto } from "node:crypto";
|
|
2
|
+
import { beforeAll, expect, test, vi } from "vitest";
|
|
3
|
+
import { EmbedError, embedFailure, embedUrl, fetchEmbedJson, solveProofOfWork } from "./embed.js";
|
|
4
|
+
|
|
5
|
+
/* The wire every embed speaks before it speaks its own, against a fake fetch and the real WebCrypto. */
|
|
6
|
+
|
|
7
|
+
// jsdom ships no SubtleCrypto; node's is the same WebCrypto the browser exposes, so the solver is exercised
|
|
8
|
+
// against the real digest rather than a stub.
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// The daemon verifies exactly this: hash the whole answer string and count the leading zero bits.
|
|
14
|
+
const leadingZeroBits = async (answer: string): Promise<number> => {
|
|
15
|
+
const digest = new Uint8Array(await webcrypto.subtle.digest("SHA-256", new TextEncoder().encode(answer)));
|
|
16
|
+
let bits = 0;
|
|
17
|
+
for (const byte of digest) {
|
|
18
|
+
if (byte !== 0) {
|
|
19
|
+
return bits + Math.clz32(byte) - 24;
|
|
20
|
+
}
|
|
21
|
+
bits += 8;
|
|
22
|
+
}
|
|
23
|
+
return bits;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
test("a door's route is spelled the daemon's way, with the automation id encoded", () => {
|
|
27
|
+
expect(embedUrl({ base: "https://sandbox.example", automationId: "a b" }, "webchat", "config")).toBe("https://sandbox.example/webchat/a%20b/config");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a refusal carries the server's own sentence and status, and a bodyless one says what it can", async () => {
|
|
31
|
+
const said = await embedFailure(new Response(JSON.stringify({ error: "origin not allowed" }), { status: 403 }));
|
|
32
|
+
expect(said).toBeInstanceOf(EmbedError);
|
|
33
|
+
expect(said).toMatchObject({ message: "origin not allowed", status: 403 });
|
|
34
|
+
expect(await embedFailure(new Response("nope", { status: 502 }))).toMatchObject({ message: "request failed (502)", status: 502 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a JSON fetch answers the body or throws the refusal", async () => {
|
|
38
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })));
|
|
39
|
+
expect(await fetchEmbedJson<{ ok: boolean }>("https://x/y")).toEqual({ ok: true });
|
|
40
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "rate limited" }), { status: 429 })));
|
|
41
|
+
await expect(fetchEmbedJson("https://x/y")).rejects.toMatchObject({ message: "rate limited", status: 429 });
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/* The one test that pays for real work: 12 bits is ~900 awaited digests, which is what carries the solver past
|
|
46
|
+
* its 512-nonce batch and through the yield the page needs to keep painting. Every digest is a round trip to
|
|
47
|
+
* the platform's crypto, so what that costs is set by how loaded the machine is and never by this code: hence
|
|
48
|
+
* a budget that bounds a hang instead of the suite's 5s hang detector for in-memory work, which a busy runner
|
|
49
|
+
* beat. The other two prove things that need no work at all, and are cheap on purpose. */
|
|
50
|
+
test("the answer really clears the difficulty, and carries the salt back for the daemon to re-derive", async () => {
|
|
51
|
+
const answer = await solveProofOfWork({ salt: "abc123", difficulty: 12 }, "needs https");
|
|
52
|
+
expect(answer.startsWith("abc123:")).toBe(true);
|
|
53
|
+
expect(await leadingZeroBits(answer)).toBeGreaterThanOrEqual(12);
|
|
54
|
+
}, 20_000);
|
|
55
|
+
|
|
56
|
+
test("a different salt yields a different answer: a solution cannot be replayed across callers", async () => {
|
|
57
|
+
const [one, two] = await Promise.all([
|
|
58
|
+
solveProofOfWork({ salt: "salt-one", difficulty: 6 }, "needs https"),
|
|
59
|
+
solveProofOfWork({ salt: "salt-two", difficulty: 6 }, "needs https"),
|
|
60
|
+
]);
|
|
61
|
+
expect(one).not.toBe(two);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an http:// page is told the truth, in the embed's own words, instead of hanging on a missing SubtleCrypto", async () => {
|
|
65
|
+
Object.defineProperty(globalThis, "crypto", { value: { randomUUID: webcrypto.randomUUID }, configurable: true });
|
|
66
|
+
await expect(solveProofOfWork({ salt: "abc", difficulty: 8 }, "This page must be served over HTTPS to start a chat.")).rejects.toThrow(/HTTPS to start a chat/);
|
|
67
|
+
Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
|
|
68
|
+
});
|
package/src/embed.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/* THE ZOD-FREE HALF OF AN EMBED'S WIRE: what every script a customer drops on their own page does before it
|
|
2
|
+
* does anything of its own. Two embeds exist, the Front Desk chat bubble (_sandbox/webchat-widget) and the bug
|
|
3
|
+
* reporter (_sandbox/issue-widget), and each used to carry its own copy of this: the same three requests
|
|
4
|
+
* against a public door, the same proof-of-work solver, the same localStorage-backed id, the same
|
|
5
|
+
* read-my-own-script-tag boot. The daemon's side of the same doors is one module too (automations/public-door.ts).
|
|
6
|
+
*
|
|
7
|
+
* THE RULE FOR WHAT MAY LIVE HERE: no imports, ever. This module is bundled INTO a page that belongs to someone
|
|
8
|
+
* else, so it must cost that page nothing it did not ask for; the contract's barrel would bring zod with it
|
|
9
|
+
* (webext-links.ts measured that at 1.1 MB for two strings). Types are declared here for the same reason. */
|
|
10
|
+
|
|
11
|
+
// Where an embed talks to: the daemon it came from, and the automation it is the public face of.
|
|
12
|
+
export interface EmbedEndpoint {
|
|
13
|
+
readonly base: string;
|
|
14
|
+
readonly automationId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// What the server said when it refused. The daemon answers every refusal as {"error": "..."}, and that sentence
|
|
18
|
+
// is shown verbatim where a person can see it: "origin not allowed" tells a site owner exactly what to fix and
|
|
19
|
+
// anything invented here would not.
|
|
20
|
+
export class EmbedError extends Error {
|
|
21
|
+
constructor(
|
|
22
|
+
message: string,
|
|
23
|
+
readonly status: number,
|
|
24
|
+
) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "EmbedError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const embedFailure = async (response: Response): Promise<EmbedError> => {
|
|
31
|
+
const body = (await response.json().catch(() => undefined)) as { error?: unknown } | undefined;
|
|
32
|
+
return new EmbedError(typeof body?.error === "string" ? body.error : `request failed (${response.status})`, response.status);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// One route of a public door: `<base>/<slug>/<automation>/<path>`, the daemon's own spelling.
|
|
36
|
+
export const embedUrl = ({ base, automationId }: EmbedEndpoint, slug: string, path: string): string =>
|
|
37
|
+
`${base}/${slug}/${encodeURIComponent(automationId)}/${path}`;
|
|
38
|
+
|
|
39
|
+
// One GET against a public door, answered as JSON or refused with the server's own sentence.
|
|
40
|
+
export const fetchEmbedJson = async <T>(url: string): Promise<T> => {
|
|
41
|
+
const response = await fetch(url);
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw await embedFailure(response);
|
|
44
|
+
}
|
|
45
|
+
return (await response.json()) as T;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/* ---- proof of work ----
|
|
49
|
+
*
|
|
50
|
+
* The daemon issues a challenge: find a nonce whose SHA-256 of `${salt}:${nonce}` begins with `difficulty` zero
|
|
51
|
+
* BITS. It costs the person a second or so and costs a script the same per identity it wants to burn, with no
|
|
52
|
+
* third-party account anywhere. The challenge is minted FOR one caller (the daemon signs the caller's own id
|
|
53
|
+
* into the salt), so a solution cannot be carried to another thread or another reporter; hence the id in the
|
|
54
|
+
* challenge request rather than a bare GET.
|
|
55
|
+
*
|
|
56
|
+
* Solved on the main thread in yielding batches rather than in a Worker: a Worker would have to come from a
|
|
57
|
+
* blob: URL, which a host page's Content-Security-Policy is entitled to forbid, and being unable to chat or send
|
|
58
|
+
* feedback because of the SITE's CSP is a worse failure than a busy second. */
|
|
59
|
+
export interface PowChallenge {
|
|
60
|
+
readonly salt: string;
|
|
61
|
+
readonly difficulty: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const fetchEmbedChallenge = (endpoint: EmbedEndpoint, slug: string, param: string, id: string): Promise<PowChallenge> =>
|
|
65
|
+
fetchEmbedJson<PowChallenge>(`${embedUrl(endpoint, slug, "challenge")}?${param}=${encodeURIComponent(id)}`);
|
|
66
|
+
|
|
67
|
+
const BATCH = 512;
|
|
68
|
+
|
|
69
|
+
// Leading zero bits of a digest, up to `wanted`. Stops at the first non-zero byte, so a miss costs one byte.
|
|
70
|
+
const leadingZeroBits = (digest: Uint8Array, wanted: number): number => {
|
|
71
|
+
let bits = 0;
|
|
72
|
+
for (const byte of digest) {
|
|
73
|
+
if (byte !== 0) {
|
|
74
|
+
// Math.clz32 counts 32-bit leading zeros; the byte sits in the low 8, so 24 of them are structural.
|
|
75
|
+
return bits + Math.clz32(byte) - 24;
|
|
76
|
+
}
|
|
77
|
+
bits += 8;
|
|
78
|
+
if (bits >= wanted) {
|
|
79
|
+
return bits;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return bits;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/* Resolves to the ANSWER the daemon expects, `<salt>:<nonce>`, carrying back the salt it signed so it can
|
|
86
|
+
* re-derive the challenge it issued without having stored one. `insecure` is what to say on an http:// page:
|
|
87
|
+
* `crypto.subtle` is only available in a secure context, and the fix is the site's TLS, not anything the
|
|
88
|
+
* person can do, so the sentence names the thing they were trying to do. */
|
|
89
|
+
export const solveProofOfWork = async (challenge: PowChallenge, insecure: string, onProgress?: (attempts: number) => void): Promise<string> => {
|
|
90
|
+
if (crypto.subtle === undefined) {
|
|
91
|
+
throw new Error(insecure);
|
|
92
|
+
}
|
|
93
|
+
const encoder = new TextEncoder();
|
|
94
|
+
for (let nonce = 0; ; nonce += 1) {
|
|
95
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(`${challenge.salt}:${nonce}`)));
|
|
96
|
+
if (leadingZeroBits(digest, challenge.difficulty) >= challenge.difficulty) {
|
|
97
|
+
return `${challenge.salt}:${nonce}`;
|
|
98
|
+
}
|
|
99
|
+
if (nonce % BATCH === BATCH - 1) {
|
|
100
|
+
onProgress?.(nonce + 1);
|
|
101
|
+
// Hand the main thread back so the page (and the embed's own "checking…" line) keeps painting.
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/* ---- what an embed keeps in the visitor's browser ----
|
|
108
|
+
*
|
|
109
|
+
* localStorage throws in Safari's private mode and anywhere the site blocks storage. An embed with no storage
|
|
110
|
+
* still works; it just mints a fresh id per page load, which costs only a slightly less useful thread or
|
|
111
|
+
* rate-limit key. Silence here is deliberate: an embed must never be the thing that puts an error in the
|
|
112
|
+
* console. */
|
|
113
|
+
export const readStored = (name: string): string | undefined => {
|
|
114
|
+
try {
|
|
115
|
+
return window.localStorage.getItem(name) ?? undefined;
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const writeStored = (name: string, value: string): void => {
|
|
122
|
+
try {
|
|
123
|
+
window.localStorage.setItem(name, value);
|
|
124
|
+
} catch {
|
|
125
|
+
/* no storage: this page load keeps the value in memory and the next one mints another */
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/* A per-browser id under `name`, minted once and kept. NOT identity and NOT a secret: anyone can mint one, and
|
|
130
|
+
* the daemon treats it as exactly what it is, the key that threads a visitor's messages into one conversation,
|
|
131
|
+
* or that a rate window counts against so one runaway tab cannot spend the whole day's budget. */
|
|
132
|
+
export const storedId = (name: string): string => {
|
|
133
|
+
const existing = readStored(name);
|
|
134
|
+
if (existing !== undefined && existing !== "") {
|
|
135
|
+
return existing;
|
|
136
|
+
}
|
|
137
|
+
const minted = crypto.randomUUID();
|
|
138
|
+
writeStored(name, minted);
|
|
139
|
+
return minted;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/* ---- the <script> tag an embed boots from ----
|
|
143
|
+
*
|
|
144
|
+
* <script src="https://sandbox-<id>.<zone>/<slug>/…js" data-automation="support" defer></script>
|
|
145
|
+
*
|
|
146
|
+
* Everything else is derived: the daemon to talk to is the ORIGIN THIS SCRIPT CAME FROM, which is the one thing
|
|
147
|
+
* a copy-pasted snippet can't get wrong. `data-base` overrides it for a site fronting the sandbox behind its
|
|
148
|
+
* own proxy, the only case where the two legitimately differ.
|
|
149
|
+
*
|
|
150
|
+
* `document.currentScript` is only valid while the script body is executing, so it is read at module scope by
|
|
151
|
+
* the caller rather than inside an async boot. The querySelector is the fallback for a bundler or tag manager
|
|
152
|
+
* that re-executes the entry in a context where currentScript is null. */
|
|
153
|
+
export const embedScript = (srcMatch: string): HTMLScriptElement | null =>
|
|
154
|
+
(document.currentScript as HTMLScriptElement | null) ?? document.querySelector<HTMLScriptElement>(`script[src*="${srcMatch}"]`);
|
|
155
|
+
|
|
156
|
+
// The endpoint a script tag names, or undefined when it carries no automation id, the one mistake worth a
|
|
157
|
+
// console line: without it the embed is silently absent and the site owner has nothing to go on.
|
|
158
|
+
export const embedEndpointOf = (script: HTMLScriptElement): EmbedEndpoint | undefined => {
|
|
159
|
+
const automationId = script.dataset["automation"];
|
|
160
|
+
if (automationId === undefined || automationId === "") {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
return { automationId, base: (script.dataset["base"] ?? new URL(script.src, window.location.href).origin).replace(/\/$/, "") };
|
|
164
|
+
};
|
package/src/events.ts
CHANGED
|
@@ -93,7 +93,7 @@ export const PermissionAskSchema = z.object({
|
|
|
93
93
|
* the title (the judge read the owner's policy and the program, and its account of why this needs asking is
|
|
94
94
|
* the only account there is), so this is left off rather than printing the same words twice. It carries the
|
|
95
95
|
* sentence on the two cards whose title says something the sentence cannot: the hard rule's, which names the
|
|
96
|
-
* consequence that stopped it, and a machine command's, which names the
|
|
96
|
+
* consequence that stopped it, and a machine command's, which names the device.
|
|
97
97
|
*
|
|
98
98
|
* Written by the quick model from the program text and the policy, never by the agent being gated — a card
|
|
99
99
|
* whose persuasive half was authored by the thing it is stopping argues for its own approval, and the turns
|
|
@@ -557,7 +557,7 @@ export const TranscriptToolSchema: z.ZodType<TranscriptTool> = z.lazy(() =>
|
|
|
557
557
|
.array(TranscriptToolSchema)
|
|
558
558
|
.optional()
|
|
559
559
|
.describe(
|
|
560
|
-
"Calls a delegated
|
|
560
|
+
"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.",
|
|
561
561
|
),
|
|
562
562
|
thinking: z.string().optional().describe("What the agent was reasoning about around this call."),
|
|
563
563
|
subagent: TranscriptSubagentSchema.optional().describe(
|
|
@@ -772,7 +772,7 @@ export const TranscriptPatchSchema = z.discriminatedUnion("op", [
|
|
|
772
772
|
op: z.literal("tool").describe("A tool card, whole: new, or the latest state of one already there, matched by id wherever it nests."),
|
|
773
773
|
index: z.number().int().nonnegative(),
|
|
774
774
|
tool: TranscriptToolSchema,
|
|
775
|
-
parent: z.string().optional().describe("The card this one nests under, when it is a delegated
|
|
775
|
+
parent: z.string().optional().describe("The card this one nests under, when it is a delegated subagent's own call."),
|
|
776
776
|
}),
|
|
777
777
|
]);
|
|
778
778
|
export type TranscriptPatch = z.infer<typeof TranscriptPatchSchema>;
|
|
@@ -857,6 +857,16 @@ export const AgentTranscriptSchema = SessionTranscriptSchema.extend({
|
|
|
857
857
|
ending: TurnEndingSchema.optional().describe(
|
|
858
858
|
"How the last turn ended, when it left work behind that one press finishes. Absent for a conversation whose last turn ended on its own, and for the failures that name something to repair first.",
|
|
859
859
|
),
|
|
860
|
+
/* WHERE THIS PAGE SITS IN THE CONVERSATION. `messages` is the tail, not the whole record: a conversation
|
|
861
|
+
* that ran all week used to be served entire to every tab that opened it and to every card the board warms
|
|
862
|
+
* behind it, which is megabytes over a tunnel to redraw a screenful.
|
|
863
|
+
*
|
|
864
|
+
* `from` is the position of the first message in the WHOLE record, which makes it two things at once: the
|
|
865
|
+
* offset every `rewindIndex` in this page is counted against, and the `before` that asks for the page above
|
|
866
|
+
* it. `more` says whether there is one, so a client can offer to go back without spending a round trip
|
|
867
|
+
* finding out. */
|
|
868
|
+
from: z.number().int().nonnegative().describe("Where the first message sits in the whole record, and the `before` that asks for the page above this one."),
|
|
869
|
+
more: z.boolean().describe("Whether older messages precede this page."),
|
|
860
870
|
});
|
|
861
871
|
|
|
862
872
|
/* WHAT A PUBLISHED CONVERSATION'S PAGE IS HANDED, the whole of it, baked into the page as one JSON block.
|
|
@@ -1269,7 +1279,7 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
1269
1279
|
// was stopped, or died under the card), which is not a decision and must not replay as one.
|
|
1270
1280
|
z.object({ kind: z.literal("resolved"), requestId: z.string(), reply: AgentReplySchema.optional() }),
|
|
1271
1281
|
/* There was a `permission_note` frame here: a late sentence raced onto a command card that had already gone
|
|
1272
|
-
* out, because the explanation was optional and the card must not wait for a
|
|
1282
|
+
* out, because the explanation was optional and the card must not wait for a one-shot helper rung that might
|
|
1273
1283
|
* take tens of seconds. It is gone with the setting that made it optional. The judge now decides the
|
|
1274
1284
|
* verdict, so the sentence is not a decoration arriving afterwards — it is the REASON THE CARD EXISTS, and
|
|
1275
1285
|
* a card cannot be raised before it is known. Nothing races, and `PermissionAsk.explain` is populated at
|
|
@@ -1334,6 +1344,23 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
1334
1344
|
"unknown-command",
|
|
1335
1345
|
"grok-model-invalid",
|
|
1336
1346
|
"codex-model-invalid",
|
|
1347
|
+
/* THE MODEL IS REAL, LISTED, AND NOT THIS PLAN'S TO RUN. A routed provider's catalog is the set
|
|
1348
|
+
* the vendor publishes, not the set the connected subscription pays for, so a picker row can be
|
|
1349
|
+
* a model the upstream refuses on sight ("Your current subscription does not have access to
|
|
1350
|
+
* kimi-for-coding-highspeed. Upgrade to higher-tier Kimi Code plans.").
|
|
1351
|
+
*
|
|
1352
|
+
* Its own code because every neighbour's recovery is wrong for it. It is not `provider-outage`,
|
|
1353
|
+
* though that is what it wore: the translator answers a refused model with a 503, the harness
|
|
1354
|
+
* reads a 5xx as an outage, and rides it out for the whole in-turn retry budget — two minutes of
|
|
1355
|
+
* a spinner for a request that was refused in five milliseconds and will be refused identically
|
|
1356
|
+
* forever, followed by an auto-resume schedule for a turn that cannot come back. It is not
|
|
1357
|
+
* `*-model-invalid` either: nothing is misspelled and reloading the catalog re-offers the same
|
|
1358
|
+
* row, because the vendor really does serve it — to somebody else.
|
|
1359
|
+
*
|
|
1360
|
+
* What changes the outcome is picking another model or buying the plan, so the sentence is the
|
|
1361
|
+
* upstream's own (it names the tier), the client holds the words, and the daemon files the model
|
|
1362
|
+
* as refused so the picker stops offering it (usage/model-refusals.ts). */
|
|
1363
|
+
"model-unavailable",
|
|
1337
1364
|
/* THE MODEL CANNOT HOLD A TURN OF THIS AGENT LOOP, so the daemon refused before sending
|
|
1338
1365
|
* (agent/context-budget.ts). Its own code because none of the neighbours describes it: nothing is
|
|
1339
1366
|
* disconnected, nothing is spent, nothing comes back on a clock, and re-sending the same request
|
package/src/fast-tier.test.ts
CHANGED
|
@@ -61,7 +61,7 @@ test("a pin on this provider wins over the catalog's own cheap end", () => {
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
test("takes a pinned id verbatim, so a model the static catalog has not caught up with is still pinnable", () => {
|
|
64
|
-
// The same call
|
|
64
|
+
// The same call resolveRoleModels makes, and for the same reason: the picker offers a custom-id escape
|
|
65
65
|
// hatch, and second-guessing the id here would run a different model than the settings row names.
|
|
66
66
|
expect(fastFor(`claude-opus-5`, { pinned: [`claude:claude-haiku-9`] })).toBe(`claude-haiku-9`);
|
|
67
67
|
});
|
package/src/fast-tier.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { compareCheapestFirst, isCheaperRung } from "./model-order.js";
|
|
2
|
-
import { parsePinned } from "./
|
|
2
|
+
import { parsePinned } from "./model-pins.js";
|
|
3
3
|
import type { AgentProvider } from "./schemas/agent.js";
|
|
4
4
|
|
|
5
5
|
/* WHICH MODEL A DOWNGRADED TURN ACTUALLY RUNS ON, the second half of automatic tier selection.
|
|
@@ -21,7 +21,7 @@ import type { AgentProvider } from "./schemas/agent.js";
|
|
|
21
21
|
* follow-up cheap to answer. So a cross-provider pin here is dropped rather than honoured: saving a fraction of
|
|
22
22
|
* a cent by starting the conversation over is not a saving.
|
|
23
23
|
*
|
|
24
|
-
* THE ORDER IS A LADDER, for the reason
|
|
24
|
+
* THE ORDER IS A LADDER, for the reason model-pins.ts is one, but a shorter one: the caller spends the head
|
|
25
25
|
* and falls back to the user's own pick, rather than walking rungs. A downgrade that cannot be started is not
|
|
26
26
|
* worth a second attempt when the honest answer (their model) is sitting right there. */
|
|
27
27
|
|
|
@@ -34,14 +34,14 @@ export interface FastTierInput {
|
|
|
34
34
|
// That provider's catalog as the caller can see it. Empty is a real state (a catalog that has not loaded),
|
|
35
35
|
// and it resolves to no downgrade rather than to a guess.
|
|
36
36
|
readonly models: readonly string[];
|
|
37
|
-
// settings.autoFastModels: an ordered list of `${provider}:${modelId}` keys (
|
|
37
|
+
// settings.autoFastModels: an ordered list of `${provider}:${modelId}` keys (modelPinKey), or empty for
|
|
38
38
|
// Auto. Entries naming another provider are dropped, not honoured.
|
|
39
39
|
readonly pinned: readonly string[];
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/* THE CHEAPER MODEL TO RUN THIS TURN ON, or undefined for "there isn't one, use their pick".
|
|
43
43
|
*
|
|
44
|
-
* A PIN IS TAKEN VERBATIM against the catalog, the same call
|
|
44
|
+
* A PIN IS TAKEN VERBATIM against the catalog, the same call resolveRoleModels makes and for the same reason:
|
|
45
45
|
* the model picker offers a custom-id escape hatch for a model the static catalog has not caught up with, and
|
|
46
46
|
* second-guessing the id here would run a different model than the settings row names. It is still checked for
|
|
47
47
|
* being CHEAPER, because that is not a fact about the catalog, it is a fact about the id, and a pin that is not
|
|
@@ -49,7 +49,7 @@ export interface FastTierInput {
|
|
|
49
49
|
*
|
|
50
50
|
* AUTO IS THE CHEAPEST ROW THE PROVIDER PUBLISHES, read through the same cheap-end order the quick model uses,
|
|
51
51
|
* so the two features cannot disagree about which rung is the cheap one. Derived, never stored: connect an
|
|
52
|
-
* account tomorrow and the answer improves by itself, exactly as
|
|
52
|
+
* account tomorrow and the answer improves by itself, exactly as a one-shot role's empty list does.
|
|
53
53
|
*
|
|
54
54
|
* NOTHING CHEAPER THAN THE PICK ⇒ UNDEFINED, and that is the common case worth being exact about rather than
|
|
55
55
|
* the edge case: a user already working on the cheap rung has nowhere to be sent, and a user on a model whose
|
package/src/history-state.ts
CHANGED
|
@@ -67,6 +67,12 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
67
67
|
{ path: "usage.jsonl", portability: "carry" },
|
|
68
68
|
{ path: "account-usage.json", portability: "carry" },
|
|
69
69
|
{ path: "provider-refusals.json", portability: "carry" },
|
|
70
|
+
/* Which MODELS this sandbox's credentials were refused (usage/model-refusals.ts), the per-model half of the
|
|
71
|
+
* entry above and carried for the same reason: a bundle is this owner's sandbox on another machine, signing
|
|
72
|
+
* the same subscriptions back in, so a plan that does not cover a model there does not cover it here
|
|
73
|
+
* either, and the picker should arrive already clean. Cheap to be wrong about in the one case it can be
|
|
74
|
+
* (the target signs in a bigger plan): every entry is forgotten a day after it was written. */
|
|
75
|
+
{ path: "model-refusals.json", portability: "carry" },
|
|
70
76
|
// Explicit first-time dependency setup requests. Carrying the worklist preserves the owner's decision when
|
|
71
77
|
// an export interrupts the queue before its terminal starts; fulfilled entries remove themselves.
|
|
72
78
|
{ path: "dependency-requests.json", portability: "carry" },
|
|
@@ -146,15 +152,18 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
146
152
|
{ path: "sync-enrollments.json", portability: "identity", note: "Re-pair desktop sync from the Sync tab." },
|
|
147
153
|
{ path: "sync-pair-consumed.json", portability: "identity" },
|
|
148
154
|
{ path: "host-enrollments.json", portability: "identity" },
|
|
149
|
-
/* A connected BROWSER's enrollment (webext/webext-
|
|
155
|
+
/* A connected BROWSER's enrollment (webext/webext-peer.ts). Identity for the hosts file's reason and one
|
|
150
156
|
* more of its own: the token admits a socket into somebody's signed-in browser, and that browser was paired
|
|
151
157
|
* with THIS sandbox — carried into another one it would either be dead weight or, worse, a second sandbox
|
|
152
158
|
* holding a live key to a browser its owner never connected it to. Re-pairing is a code and one click. */
|
|
153
159
|
{ path: "webext-enrollments.json", portability: "identity", note: "Pair your browser again from its card: the extension is still installed." },
|
|
154
|
-
// The burn list
|
|
160
|
+
// The browser door's burn list: every peer door keeps one (peers/peer-store.ts), and a browser pairing
|
|
161
|
+
// redeemed here must not read as fresh in a sandbox this file travelled to. Identity, hosts' reason.
|
|
162
|
+
{ path: "webext-pair-consumed.json", portability: "identity" },
|
|
163
|
+
// The burn list for setup-time device pairings. Identity, like sync's beside it, and for a sharper reason:
|
|
155
164
|
// carrying it into another sandbox would mark that sandbox's own fresh pairing as already spent.
|
|
156
165
|
{ path: "host-pair-consumed.json", portability: "identity" },
|
|
157
|
-
// A runner's enrollment names THIS sandbox as its parent (runners/
|
|
166
|
+
// A runner's enrollment names THIS sandbox as its parent (runners/runner-peer.ts): in another sandbox the
|
|
158
167
|
// digest would admit a socket whose runner still dials the old parent. Identity, both files, hosts' reasons.
|
|
159
168
|
{ path: "runner-enrollments.json", portability: "identity" },
|
|
160
169
|
{ path: "runner-pair-consumed.json", portability: "identity" },
|