@intentic/sandbox-contract 1.222.0 → 1.224.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/dist/chores/chores.js +1 -1
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/prompt.d.ts.map +1 -1
- package/dist/chores/prompt.js +3 -3
- package/dist/chores/prompt.js.map +1 -1
- package/dist/contracts/ports.contract.d.ts +11 -0
- package/dist/contracts/ports.contract.d.ts.map +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/schemas.d.ts +22 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -0
- package/dist/schemas.js.map +1 -1
- package/package.json +4 -4
- package/src/agent-catalog.ts +78 -78
- package/src/agent-run-model.ts +8 -8
- package/src/capability-env.ts +3 -3
- package/src/capability-secrets.ts +5 -5
- package/src/chores/chores.ts +67 -67
- package/src/chores/digest.ts +3 -3
- package/src/chores/extension-update.ts +3 -3
- package/src/chores/fix-deps.ts +3 -3
- package/src/chores/probes.ts +16 -16
- package/src/chores/prompt.ts +9 -9
- package/src/chores/stack.ts +17 -17
- package/src/chores/verdict.ts +23 -23
- package/src/contract-lock.ts +7 -7
- package/src/contracts/activity.contract.ts +1 -1
- package/src/contracts/agent.contract.ts +2 -2
- package/src/contracts/agents.contract.ts +11 -11
- package/src/contracts/automations.contract.ts +5 -5
- package/src/contracts/capabilities.contract.ts +7 -7
- package/src/contracts/chores.contract.ts +3 -3
- package/src/contracts/ci.contract.ts +2 -2
- package/src/contracts/claude.contract.ts +3 -3
- package/src/contracts/drafts.contract.ts +1 -1
- package/src/contracts/endpoints.contract.ts +2 -2
- package/src/contracts/extensions.contract.ts +8 -8
- package/src/contracts/git.contract.ts +6 -6
- package/src/contracts/grok.contract.ts +4 -4
- package/src/contracts/host.contract.ts +5 -5
- package/src/contracts/intentic.contract.ts +2 -2
- package/src/contracts/logs.contract.ts +1 -1
- package/src/contracts/loops.contract.ts +8 -8
- package/src/contracts/personas.contract.ts +8 -8
- package/src/contracts/ports.contract.ts +1 -1
- package/src/contracts/prepush.contract.ts +2 -2
- package/src/contracts/providers.contract.ts +4 -4
- package/src/contracts/public.contract.ts +1 -1
- package/src/contracts/push.contract.ts +2 -2
- package/src/contracts/secrets.contract.ts +2 -2
- package/src/contracts/sessions.contract.ts +2 -2
- package/src/contracts/settings.contract.ts +3 -3
- package/src/contracts/share.contract.ts +1 -1
- package/src/contracts/skills.contract.ts +2 -2
- package/src/contracts/system.contract.ts +9 -9
- package/src/contracts/usage.contract.ts +1 -1
- package/src/contracts/vpn.contract.ts +5 -5
- package/src/contracts/workflows.contract.ts +12 -12
- package/src/contracts/workspace.contract.ts +13 -13
- package/src/conversation-ids.ts +7 -7
- package/src/events.ts +136 -136
- package/src/history-state.ts +12 -12
- package/src/host-protocol.ts +7 -7
- package/src/hostnames.ts +15 -15
- package/src/index.ts +8 -8
- package/src/listener-protocol.ts +12 -12
- package/src/model-order.ts +30 -30
- package/src/output-fields.ts +9 -9
- package/src/path-refs.ts +6 -6
- package/src/publish-drafts.ts +6 -6
- package/src/quick-model.ts +15 -15
- package/src/routes.ts +19 -19
- package/src/runtime-state.ts +22 -22
- package/src/schemas.ts +930 -918
- package/src/search-globs.ts +6 -6
- package/src/session-names.ts +5 -5
- package/src/share-paths.ts +7 -7
- package/src/sse.ts +1 -1
- package/src/state-portability.ts +8 -8
- package/src/terminal-protocol.ts +3 -3
- package/src/title.ts +20 -20
- package/src/tunnel-ids.ts +7 -7
- package/src/versions.ts +5 -5
- package/src/workflow-faults.ts +11 -11
- package/src/workspace-state.ts +119 -119
package/src/output-fields.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
/* A DECLARED OUTPUT SHAPE
|
|
3
|
+
/* A DECLARED OUTPUT SHAPE, the answer to "what does this session produce?", written once and used three ways.
|
|
4
4
|
*
|
|
5
5
|
* An agentic session's natural output is prose, and prose is unusable as an input to the next session: a step
|
|
6
6
|
* that must hand "the three files worth changing" to the step after it cannot hand over a paragraph that
|
|
7
7
|
* mentions them. So a session that feeds another one declares its shape here, and that one declaration
|
|
8
8
|
* becomes: the sentence in the prompt that tells the model what to write, the validator that decides whether
|
|
9
9
|
* it complied, and the table the run view renders. Written once because the three drift apart the moment they
|
|
10
|
-
* are written twice
|
|
10
|
+
* are written twice, a prompt asking for `files` and a validator wanting `paths` fails on every iteration and
|
|
11
11
|
* says nothing useful about why.
|
|
12
12
|
*
|
|
13
13
|
* WHY A FIELD LIST AND NOT JSON SCHEMA. JSON Schema is strictly more expressive and completely unauthorable in
|
|
14
14
|
* a form: nobody designing a workflow is going to hand-write `{"type":"object","properties":{...}}`, and a UI
|
|
15
15
|
* that generates it becomes a schema editor, which is a product of its own. Four scalar types plus a string
|
|
16
|
-
* list covers what one session actually hands another
|
|
16
|
+
* list covers what one session actually hands another, a verdict, a count, a list of paths, a summary, and
|
|
17
17
|
* anything past that is better carried as a file the next step reads.
|
|
18
18
|
*
|
|
19
19
|
* `description` IS REQUIRED, and that is the field that decides whether this works at all. `{name: "risk"}`
|
|
20
|
-
* gets you the model's guess at what risk means; `{name: "risk", description: "high | medium | low
|
|
20
|
+
* gets you the model's guess at what risk means; `{name: "risk", description: "high | medium | low, how
|
|
21
21
|
* likely this change is to break something at runtime"}` gets you the answer to the question you asked.
|
|
22
22
|
*/
|
|
23
23
|
|
|
@@ -28,14 +28,14 @@ const FIELD_NAME = /^[a-zA-Z_][a-zA-Z0-9_]{0,39}$/;
|
|
|
28
28
|
export const OutputFieldSchema = z.object({
|
|
29
29
|
name: z.string().regex(FIELD_NAME),
|
|
30
30
|
type: z.enum(["string", "number", "boolean", "string[]"]),
|
|
31
|
-
// What the field means, in the words the model is given. Not optional
|
|
31
|
+
// What the field means, in the words the model is given. Not optional, see the note above.
|
|
32
32
|
description: z.string().min(1),
|
|
33
33
|
// An absent optional field validates; an absent required one does not, and the iteration is told which.
|
|
34
34
|
required: z.boolean(),
|
|
35
35
|
});
|
|
36
36
|
export type OutputField = z.infer<typeof OutputFieldSchema>;
|
|
37
37
|
|
|
38
|
-
// How many fields one output may declare. A shape past this is not a handoff, it is a report
|
|
38
|
+
// How many fields one output may declare. A shape past this is not a handoff, it is a report, and a report is
|
|
39
39
|
// what the prose half of the document is for.
|
|
40
40
|
export const OUTPUT_FIELDS_MAX = 16;
|
|
41
41
|
|
|
@@ -81,13 +81,13 @@ const validatorFor = (field: OutputField): z.ZodType => {
|
|
|
81
81
|
/* The declared shape as a validator. Unknown keys are ALLOWED THROUGH: a model that answered everything asked
|
|
82
82
|
* of it and then added a `notes` key has complied, and failing it there would burn an iteration teaching it to
|
|
83
83
|
* write less. What is enforced is that every required field is present and every present field has the
|
|
84
|
-
* declared type
|
|
84
|
+
* declared type, the two things the reader downstream is entitled to assume.
|
|
85
85
|
*/
|
|
86
86
|
export const fieldsValidator = (fields: readonly OutputField[]): z.ZodType =>
|
|
87
87
|
z.looseObject(Object.fromEntries(fields.map((field) => [field.name, field.required ? validatorFor(field) : validatorFor(field).optional()])));
|
|
88
88
|
|
|
89
89
|
// A worked example of the declared shape, so the prompt can show rather than describe. Values are the field's
|
|
90
|
-
// own description
|
|
90
|
+
// own description, a model copying the example's structure has the description in front of it as it fills each
|
|
91
91
|
// slot, which is where it is needed rather than in a legend three lines up.
|
|
92
92
|
export const fieldsExample = (fields: readonly OutputField[]): Record<string, unknown> =>
|
|
93
93
|
Object.fromEntries(
|
|
@@ -106,6 +106,6 @@ export const fieldsExample = (fields: readonly OutputField[]): Record<string, un
|
|
|
106
106
|
}),
|
|
107
107
|
);
|
|
108
108
|
|
|
109
|
-
// One line per field, for surfaces with no room to render an example: "risk (string, required)
|
|
109
|
+
// One line per field, for surfaces with no room to render an example: "risk (string, required), how likely …".
|
|
110
110
|
export const describeFields = (fields: readonly OutputField[]): string =>
|
|
111
111
|
fields.map((field) => `- \`${field.name}\` (${field.type}${field.required ? ", required" : ", optional"}) — ${field.description}`).join(`\n`);
|
package/src/path-refs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Which file a NAMED reference means. A path written in prose is only loosely anchored to the workspace: an
|
|
2
2
|
* agent that has been working in `_editor/web/src` writes `pages/workspace/Foo.vue`, and a turn running in an
|
|
3
|
-
* isolated worktree prints `/history/worktrees/<id>/_editor/web/src/foo.ts
|
|
3
|
+
* isolated worktree prints `/history/worktrees/<id>/_editor/web/src/foo.ts`, neither is the workspace-relative
|
|
4
4
|
* path the file routes speak, but both END in it.
|
|
5
5
|
*
|
|
6
6
|
* So a reference is resolved by matching progressively shorter TAILS of it against the real tree. The rules
|
|
@@ -32,17 +32,17 @@ export const referenceTails = (raw: string, root: string): readonly string[] =>
|
|
|
32
32
|
return tails;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
// The paths that genuinely END in `tail` on a segment boundary, best first
|
|
35
|
+
// The paths that genuinely END in `tail` on a segment boundary, best first, the shared ranking both matchers
|
|
36
36
|
// return their candidates in. Shallowest wins: `pages/Foo.vue` means the app's page, not the copy six
|
|
37
|
-
// directories down in a fixture tree. (The daemon's glob is anchored only at the string level
|
|
38
|
-
// also matches `mypages/x.vue
|
|
37
|
+
// directories down in a fixture tree. (The daemon's glob is anchored only at the string level, `**/pages/x.vue`
|
|
38
|
+
// also matches `mypages/x.vue`, so the boundary is enforced here rather than by the pattern.)
|
|
39
39
|
export const rankRefCandidates = (tail: string, paths: readonly string[]): readonly string[] =>
|
|
40
40
|
paths
|
|
41
41
|
.filter((path) => path === tail || path.endsWith(`/${tail}`))
|
|
42
42
|
.toSorted((a, b) => a.split(`/`).length - b.split(`/`).length || a.length - b.length || (a < b ? -1 : 1))
|
|
43
43
|
.slice(0, MAX_REF_CANDIDATES);
|
|
44
44
|
|
|
45
|
-
/* IS THIS FILE TEST CODE
|
|
45
|
+
/* IS THIS FILE TEST CODE, the one classification rule for every surface that splits a diff into "the
|
|
46
46
|
* change" and "the proof". The agent review header answers "how much of this is tests?" with it; anything
|
|
47
47
|
* else that wants the split (fleet cards, commit summaries) must use this same predicate, because two
|
|
48
48
|
* classifiers that disagree turn the readout into a lie the user can't detect.
|
|
@@ -50,7 +50,7 @@ export const rankRefCandidates = (tail: string, paths: readonly string[]): reado
|
|
|
50
50
|
* Convention-based, matching what this monorepo (and the ecosystems it scaffolds) actually writes: a
|
|
51
51
|
* `.test.` / `.spec.` filename in any extension, a `__tests__` / `__fixtures__` directory anywhere on the
|
|
52
52
|
* path, an `e2e-harness`, or a test-runner config. Deliberately NOT "anything containing 'test'": a
|
|
53
|
-
* `testimonials/` page or a `latest.ts` is product code, and a false "tests" tag is worse than a missed one
|
|
53
|
+
* `testimonials/` page or a `latest.ts` is product code, and a false "tests" tag is worse than a missed one,
|
|
54
54
|
* it tells a reviewer not to look. */
|
|
55
55
|
const TEST_DIRS = new Set([`__tests__`, `__fixtures__`, `__mocks__`, `__snapshots__`]);
|
|
56
56
|
const TEST_FILE =
|
package/src/publish-drafts.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
/* HOW AN APPROVED POST GETS SENT
|
|
1
|
+
/* HOW AN APPROVED POST GETS SENT, the shared half, written once because the daemon acts on it, the app draws
|
|
2
2
|
* it, and the two have to agree on the same seconds.
|
|
3
3
|
*
|
|
4
4
|
* THERE IS NO PUBLISHER AUTOMATION ANY MORE, and its absence is the design. Publishing used to be a scheduled
|
|
5
5
|
* automation: a cron waking every few minutes, running a shell guard over the drafts directory, almost always
|
|
6
|
-
* finding nothing
|
|
6
|
+
* finding nothing, a job whose entire job was to ask "yet?" forever. It also made the approve button
|
|
7
7
|
* conditional on a row in a list nobody had asked for: delete the automation and approvals silently went
|
|
8
8
|
* nowhere, with the button still there and still saying yes. The daemon owns publishing now
|
|
9
9
|
* (drafts-publisher.ts). It knows the moment a draft comes due because it is the process that wrote the draft,
|
|
10
10
|
* so it sleeps until exactly then and costs nothing in between.
|
|
11
11
|
*
|
|
12
12
|
* APPROVAL IS NOT "SEND NOW", IT IS "SEND UNLESS I STOP YOU". A post is public and permanent the instant it
|
|
13
|
-
* lands, and the gap between realising and clicking is about two seconds
|
|
13
|
+
* lands, and the gap between realising and clicking is about two seconds, so an approved draft carrying no
|
|
14
14
|
* date of its own is dated HOLD into the future, and the queue counts it down in the open. The hold is stored
|
|
15
15
|
* as an ordinary scheduledAt rather than as a new state, which is what keeps it honest: it survives a restart,
|
|
16
16
|
* it reads as one number in the same place a scheduled post's date is already read, and calling it off is the
|
|
@@ -21,20 +21,20 @@
|
|
|
21
21
|
export const APPROVAL_HOLD_MS = 60_000;
|
|
22
22
|
|
|
23
23
|
/* WHO CAN BE SENT BY CODE ALONE. A connector reached through a real API with a stored credential is a request
|
|
24
|
-
* the daemon can make itself
|
|
24
|
+
* the daemon can make itself, no model, no browser, no turn, and it either got a 200 or it did not. A
|
|
25
25
|
* connector that IS a logged-in browser session (reddit, x) has no such door: posting there means driving a
|
|
26
26
|
* page whose markup moves under you, past dialogs and rate screens nobody can enumerate in advance, which is
|
|
27
27
|
* precisely the work an agent turn exists to absorb.
|
|
28
28
|
*
|
|
29
29
|
* So the split follows what the platform actually offers rather than what would be cheaper, and it is stated
|
|
30
|
-
* here rather than guessed at a call site
|
|
30
|
+
* here rather than guessed at a call site, because being wrong in the optimistic direction means a post that
|
|
31
31
|
* silently never goes out. A platform absent from this set is published by an agent turn, which always works
|
|
32
32
|
* and merely costs more. */
|
|
33
33
|
export const DIRECT_PUBLISH_PLATFORMS: ReadonlySet<string> = new Set(["discord"]);
|
|
34
34
|
|
|
35
35
|
/* WHAT THE PUBLISH TURN IS TOLD, for the drafts no API can carry. It NAMES the drafts rather than saying "go
|
|
36
36
|
* and look": the daemon has already decided what is due, and a turn that re-derives that decision can disagree
|
|
37
|
-
* with it
|
|
37
|
+
* with it, sending something the owner pulled back a second ago, or skipping something it judged not ready.
|
|
38
38
|
* The turn's job is the part only it can do, which is working the platform's own UI.
|
|
39
39
|
*
|
|
40
40
|
* It still writes the outcome back into the file, because the file is where the queue reads it, and a post
|
package/src/quick-model.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { ACCESS_COST, accessFor, modelsFor, PROVIDERS } from "./agent-catalog.js
|
|
|
2
2
|
import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
|
|
3
3
|
import type { AgentProvider } from "./schemas.js";
|
|
4
4
|
|
|
5
|
-
/* THE QUICK MODEL
|
|
5
|
+
/* THE QUICK MODEL, the cheap, fast model a small automatic job spends instead of the frontier model the chat
|
|
6
6
|
* runs on. Today that is the commit message written when an agent's work lands; anything else of that shape (a
|
|
7
7
|
* branch name, a PR description) reads the same answer, which is the reason this is a `quickModel` setting
|
|
8
8
|
* rather than a commit-message one.
|
|
@@ -11,11 +11,11 @@ import type { AgentProvider } from "./schemas.js";
|
|
|
11
11
|
* failure: the account it names spends its allowance on the chat all morning, and every job for the rest of the
|
|
12
12
|
* day fails on a limit while three other connected providers sit idle. So the setting is a LIST
|
|
13
13
|
* read top to bottom, the resolver hands back the whole ladder, and the daemon walks it until one answers.
|
|
14
|
-
* Nothing here decides WHICH failures are worth stepping over
|
|
15
|
-
* the call
|
|
14
|
+
* Nothing here decides WHICH failures are worth stepping over, that is the daemon's, since only it has run
|
|
15
|
+
* the call, this side only says what the running order is.
|
|
16
16
|
*
|
|
17
17
|
* The rule lives in the contract because BOTH sides need the same answer for different jobs: the daemon runs
|
|
18
|
-
* the model, and the browser has to NAME it
|
|
18
|
+
* the model, and the browser has to NAME it, in the settings row's "Auto (…)" label, before anything has been
|
|
19
19
|
* run. Two implementations would drift precisely where it matters most, since a label promising Haiku while the
|
|
20
20
|
* daemon bills Opus is worse than no label.
|
|
21
21
|
*
|
|
@@ -27,14 +27,14 @@ import type { AgentProvider } from "./schemas.js";
|
|
|
27
27
|
|
|
28
28
|
/* One provider's standing in the decision: whether a turn on it can be sent at all, and what its catalog holds.
|
|
29
29
|
*
|
|
30
|
-
* ACP agents are deliberately not expressible here
|
|
30
|
+
* ACP agents are deliberately not expressible here, an ACP row's model id is empty because the agent owns its
|
|
31
31
|
* own model, so there is no cheap rung to point it at. `endpoint/<id>` providers ARE, and have to be: their
|
|
32
32
|
* models appear in the same picker the settings row builds its options from, so a pin naming one has to hold
|
|
33
33
|
* rather than fall silently back to Auto and spend an account the user was deliberately steering away from. */
|
|
34
34
|
export interface QuickModelSource {
|
|
35
35
|
// AgentProvider, not NativeProvider: an endpoint's id is user-created and cannot be in a fixed union. Auto's
|
|
36
|
-
// ranking degrades gracefully for one
|
|
37
|
-
// UNRANKED, which is genuine last place
|
|
36
|
+
// ranking degrades gracefully for one, costOf falls to the metered rung and an id with no tier word is
|
|
37
|
+
// UNRANKED, which is genuine last place, so an endpoint effectively only wins Auto when nothing else is
|
|
38
38
|
// connected, while a PIN on one holds. Both are the right answers: what a turn on someone's own model server
|
|
39
39
|
// costs is not a fact this repo can know, so it is not one Auto should be asserting.
|
|
40
40
|
readonly provider: AgentProvider;
|
|
@@ -67,13 +67,13 @@ export const parsePinned = (pinned: string): QuickModelChoice | undefined => {
|
|
|
67
67
|
|
|
68
68
|
/* A pin as a person reads it: the catalog's own label for the id, or the id itself for one the static catalog
|
|
69
69
|
* has not caught up with (the picker offers a custom-id escape hatch, so this is a real case rather than a
|
|
70
|
-
* defensive branch). Beside parsePinned because the two are always wanted together
|
|
70
|
+
* defensive branch). Beside parsePinned because the two are always wanted together, by any surface that has to
|
|
71
71
|
* name what a click is about to spend BEFORE it spends it, and the two loudest of those are extensions that
|
|
72
72
|
* share no other code with each other. */
|
|
73
73
|
export const pinnedModelLabel = (choice: QuickModelChoice): string =>
|
|
74
74
|
modelsFor(choice.provider).find((option) => option.value === choice.model)?.label ?? choice.model;
|
|
75
75
|
|
|
76
|
-
// The cheapest row a provider publishes
|
|
76
|
+
// The cheapest row a provider publishes, its whole catalog read from the cheap end. Undefined for a catalog
|
|
77
77
|
// that hasn't loaded yet, which is a real state: every provider serves a floor, but only once something has
|
|
78
78
|
// asked it.
|
|
79
79
|
const cheapestOf = (source: QuickModelSource): string | undefined => source.models.toSorted(compareCheapestFirst)[0];
|
|
@@ -83,28 +83,28 @@ const cheapestOf = (source: QuickModelSource): string | undefined => source.mode
|
|
|
83
83
|
// so the row is the provider's base line rather than its budget one.
|
|
84
84
|
const tierOf = (model: string): number => tierRankOf(familyOf(model));
|
|
85
85
|
|
|
86
|
-
// PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read
|
|
86
|
+
// PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read, the property
|
|
87
87
|
// compareUnrankedModelIds exists to guarantee, and the one a default actually needs. An endpoint is in no fixed
|
|
88
88
|
// list, so it reads -1 and leads the tiebreak; unreachable in practice, since it can never tie on cost.
|
|
89
89
|
const providerOrder = (provider: AgentProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
|
|
90
90
|
|
|
91
91
|
// How much a call on this provider costs at the margin. Every native provider declares an access kind; an
|
|
92
|
-
// endpoint declares none, and takes the metered rung
|
|
92
|
+
// endpoint declares none, and takes the metered rung, the conservative reading of a model API whose bill this
|
|
93
93
|
// repo cannot see, which keeps Auto from reaching for someone's paid gateway on its own initiative.
|
|
94
94
|
const costOf = (provider: AgentProvider): number => {
|
|
95
95
|
const access = accessFor(provider);
|
|
96
96
|
return access === undefined ? ACCESS_COST.key : ACCESS_COST[access.kind];
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
-
/* AUTO
|
|
99
|
+
/* AUTO, every connected provider's cheapest row, best-first, as a ladder rather than a winner.
|
|
100
100
|
*
|
|
101
101
|
* Ranked on TIER FIRST, then cost. That order is the point of the feature: the helper exists to not be the
|
|
102
102
|
* frontier model, so a free flagship is still the wrong tool, while a free Haiku-class row and a subscription
|
|
103
103
|
* Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the channel the user
|
|
104
|
-
* is not paying per token for
|
|
104
|
+
* is not paying per token for, and against the one they are.
|
|
105
105
|
*
|
|
106
106
|
* The whole ladder, not just its head, because the same ranking that picks the best answer also states the best
|
|
107
|
-
* SECOND answer
|
|
107
|
+
* SECOND answer, and a sandbox with three accounts connected should not lose its commit messages for six hours
|
|
108
108
|
* because one of them is spent. */
|
|
109
109
|
const autoLadder = (sources: readonly QuickModelSource[]): readonly QuickModelChoice[] =>
|
|
110
110
|
sources
|
|
@@ -144,7 +144,7 @@ export const resolveQuickModels = (sources: readonly QuickModelSource[], pinned:
|
|
|
144
144
|
const choice = parsePinned(key);
|
|
145
145
|
return choice === undefined || !ready.has(choice.provider) ? [] : [choice];
|
|
146
146
|
});
|
|
147
|
-
// The same model twice would spend two attempts proving the same account is out
|
|
147
|
+
// The same model twice would spend two attempts proving the same account is out, a real state, since the
|
|
148
148
|
// list is edited by hand and Auto's ladder can rank a provider the user has also pinned.
|
|
149
149
|
const chain = [...new Map(requested.map((choice) => [quickModelKey(choice), choice])).values()];
|
|
150
150
|
return chain.length > 0 ? chain : autoLadder(sources);
|
package/src/routes.ts
CHANGED
|
@@ -6,18 +6,18 @@ import { z } from "zod";
|
|
|
6
6
|
* normal and neither should force an update.
|
|
7
7
|
*
|
|
8
8
|
* What must not happen is the failure being SILENT. A route the daemon predates answers 404, which the browser
|
|
9
|
-
* has no way to tell apart from "you asked for a file that isn't there"
|
|
9
|
+
* has no way to tell apart from "you asked for a file that isn't there", so a missing feature reads as a
|
|
10
10
|
* broken one, and diagnosing it costs an hour of "did the image rebuild?".
|
|
11
11
|
*
|
|
12
12
|
* So the daemon ADVERTISES the routes it implements (the /events hello frame) and the browser compares that
|
|
13
13
|
* against the contract it was itself built with. Everything present on both sides works exactly as before;
|
|
14
14
|
* anything the daemon lacks is a KNOWN, NAMED gap the UI can gate a feature on or explain in an error, instead
|
|
15
|
-
* of a mystery 404. Old daemon + new browser stays fully supported
|
|
15
|
+
* of a mystery 404. Old daemon + new browser stays fully supported, it just stops being confusing.
|
|
16
16
|
*
|
|
17
17
|
* Route names are `<group>.<route>` (`vpn.list`, `kimi.models`), derived from the contract object both sides
|
|
18
18
|
* import. Nothing is generated and nothing is hand-maintained: adding a route to the contract adds it here.
|
|
19
19
|
*
|
|
20
|
-
* Everything here is a pure function of a contract passed in
|
|
20
|
+
* Everything here is a pure function of a contract passed in, index.ts binds them to `sandboxContract` once it
|
|
21
21
|
* is assembled, which is what keeps this module out of an import cycle with it. */
|
|
22
22
|
|
|
23
23
|
// The shape we read off an oRPC contract procedure. `~orpc.route` is the contract metadata oRPC attaches to
|
|
@@ -43,7 +43,7 @@ const procedureRoute = (value: unknown): { method: string; path: string } | unde
|
|
|
43
43
|
export interface ContractRoute {
|
|
44
44
|
readonly name: string;
|
|
45
45
|
readonly method: string;
|
|
46
|
-
// The oRPC path template, with `{param}` placeholders
|
|
46
|
+
// The oRPC path template, with `{param}` placeholders, e.g. `/system/terminals/{name}`.
|
|
47
47
|
readonly path: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -69,7 +69,7 @@ export const contractRoutes = (contract: Record<string, unknown>): ContractRoute
|
|
|
69
69
|
*
|
|
70
70
|
* The route list above answers "does this daemon have it". It cannot answer the other half: a route that
|
|
71
71
|
* exists on BOTH sides but whose payload changed between the two builds. Names match, so the call goes out,
|
|
72
|
-
* and a field the browser expects is simply missing from the answer
|
|
72
|
+
* and a field the browser expects is simply missing from the answer, no 404, no message, nothing to attribute
|
|
73
73
|
* it to. That is the one skew failure the named-gap mechanism still leaves silent.
|
|
74
74
|
*
|
|
75
75
|
* So each route also advertises a fingerprint of its wire shape: input and output schema together, reduced to
|
|
@@ -82,25 +82,25 @@ export const contractRoutes = (contract: Record<string, unknown>): ContractRoute
|
|
|
82
82
|
* "hangs on someone remembering"). Changing a field in a contract schema changes that route's fingerprint by
|
|
83
83
|
* itself, on both sides, with nobody to remember anything. */
|
|
84
84
|
|
|
85
|
-
// The schemas oRPC hangs off a contract procedure, read structurally for the same reason as `route` above
|
|
85
|
+
// The schemas oRPC hangs off a contract procedure, read structurally for the same reason as `route` above,
|
|
86
86
|
// they are internal metadata, not part of its public surface.
|
|
87
87
|
interface ContractSchemasLike {
|
|
88
88
|
readonly "~orpc": { readonly inputSchema?: unknown; readonly outputSchema?: unknown };
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/* The JSON Schema keywords whose array value is a SET
|
|
91
|
+
/* The JSON Schema keywords whose array value is a SET, order carries no meaning, so two schemas that differ
|
|
92
92
|
* only in it are the same wire shape. `required` is the one that matters in practice: JSON Schema spells an
|
|
93
93
|
* object's fields twice, once as `properties` (an object, so key-sorting settles it) and once as `required`
|
|
94
|
-
* (an array, in declaration order)
|
|
94
|
+
* (an array, in declaration order), so without this, moving a field up a `z.object` for readability reads as
|
|
95
95
|
* drift on every route that carries it. The union keywords are here for the same reason a step lower down. */
|
|
96
96
|
const UNORDERED = new Set(["required", "enum", "anyOf", "oneOf", "allOf"]);
|
|
97
97
|
|
|
98
98
|
/* Canonical JSON: object keys sorted, set-valued arrays sorted, everything else left in the order it was
|
|
99
|
-
* written. So the fingerprint depends on what the schema SAYS rather than how zod happened to emit it
|
|
99
|
+
* written. So the fingerprint depends on what the schema SAYS rather than how zod happened to emit it, which
|
|
100
100
|
* is what keeps a purely cosmetic edit from being reported to a user as a payload that moved.
|
|
101
101
|
*
|
|
102
102
|
* Ordered arrays stay ordered: a tuple's `prefixItems` is positional, and sorting it would call two genuinely
|
|
103
|
-
* different shapes identical
|
|
103
|
+
* different shapes identical, the failure that actually costs something here. */
|
|
104
104
|
const canonical = (value: unknown, key?: string): unknown => {
|
|
105
105
|
if (Array.isArray(value)) {
|
|
106
106
|
const items = value.map((item) => canonical(item));
|
|
@@ -114,7 +114,7 @@ const canonical = (value: unknown, key?: string): unknown => {
|
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
/* FNV-1a over the canonical JSON, base36. Deliberately not a cryptographic hash: this is a drift SIGNAL, and
|
|
117
|
-
* the cost of a collision is one warning that never appears
|
|
117
|
+
* the cost of a collision is one warning that never appears, never a wrong action, never a blocked call. A
|
|
118
118
|
* crypto digest would mean a subtle-crypto round trip in the browser (async, and unavailable on insecure
|
|
119
119
|
* origins) to buy nothing this use has any need of. */
|
|
120
120
|
const fingerprint = (value: unknown): string => {
|
|
@@ -133,8 +133,8 @@ const fingerprint = (value: unknown): string => {
|
|
|
133
133
|
* way out, so reading both sides in the same direction would call two genuinely different shapes identical.
|
|
134
134
|
*
|
|
135
135
|
* A schema that will not convert yields undefined rather than throwing the walk away. Every such route in the
|
|
136
|
-
* contract today is a streaming one
|
|
137
|
-
* underneath it
|
|
136
|
+
* contract today is a streaming one, oRPC wraps an event iterator's output in an opaque type with no schema
|
|
137
|
+
* underneath it, and their absence lands them in exactly the right place: a route with no advertised shape is
|
|
138
138
|
* assumed compatible, which is the same "positive evidence only" rule the missing-route check already runs on. */
|
|
139
139
|
const procedureShape = (value: unknown): string | undefined => {
|
|
140
140
|
if (typeof value !== "object" || value === null || !("~orpc" in value)) {
|
|
@@ -174,7 +174,7 @@ export const routeShapes = (contract: Record<string, unknown>): Record<string, s
|
|
|
174
174
|
};
|
|
175
175
|
|
|
176
176
|
// Does a concrete request path match this route's template? Segment-wise, with `{param}` matching exactly one
|
|
177
|
-
// segment
|
|
177
|
+
// segment, the same shape oRPC mounts, so a template can never match a longer or shorter path.
|
|
178
178
|
const pathMatches = (template: string, path: string): boolean => {
|
|
179
179
|
const wanted = template.split("/");
|
|
180
180
|
const actual = path.split("/");
|
|
@@ -185,7 +185,7 @@ const pathMatches = (template: string, path: string): boolean => {
|
|
|
185
185
|
};
|
|
186
186
|
|
|
187
187
|
// The contract route a concrete request belongs to, or undefined when the path is not a contract route at all
|
|
188
|
-
// (the daemon also serves hand-written Hono routes like /health and /workspace/raw
|
|
188
|
+
// (the daemon also serves hand-written Hono routes like /health and /workspace/raw, those are never gated).
|
|
189
189
|
// The query string is stripped first; callers pass whatever they handed to fetch.
|
|
190
190
|
export const routeNameForRequest = (routes: readonly ContractRoute[], method: string, pathWithQuery: string): string | undefined => {
|
|
191
191
|
const path = pathWithQuery.split("?")[0] ?? pathWithQuery;
|
|
@@ -193,18 +193,18 @@ export const routeNameForRequest = (routes: readonly ContractRoute[], method: st
|
|
|
193
193
|
return routes.find((route) => route.method.toUpperCase() === upper && pathMatches(route.path, path))?.name;
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
-
// The route a typed client call belongs to. oRPC addresses a procedure by its position in the contract
|
|
197
|
-
// `['git','stashApply']
|
|
196
|
+
// The route a typed client call belongs to. oRPC addresses a procedure by its position in the contract,
|
|
197
|
+
// `['git','stashApply']`, which is the contract's own name for it, so this is a lookup rather than a match.
|
|
198
198
|
export const routeForProcedure = (routes: readonly ContractRoute[], procedure: readonly string[]): ContractRoute | undefined =>
|
|
199
199
|
routes.find((route) => route.name === procedure.join("."));
|
|
200
200
|
|
|
201
|
-
// THE CONCRETE PATH A TYPED CALL WILL CARRY
|
|
201
|
+
// THE CONCRETE PATH A TYPED CALL WILL CARRY, the route template with every `{param}` replaced by the input
|
|
202
202
|
// field of the same name, which is how the OpenAPI link fills them.
|
|
203
203
|
//
|
|
204
204
|
// This exists so a permission gate can check the string the daemon will actually route on rather than the
|
|
205
205
|
// template it came from. Checking the template would quietly widen every grant: a wildcard glob matches the
|
|
206
206
|
// literal `{repo}` braces just as happily as it matches a repo name, so a manifest that narrowed the grant to
|
|
207
|
-
// one repo would still admit calls for every other
|
|
207
|
+
// one repo would still admit calls for every other, the gate would be comparing two patterns instead of
|
|
208
208
|
// testing a value against one.
|
|
209
209
|
//
|
|
210
210
|
// A param with no matching input field keeps its placeholder. That cannot be reached through the typed client
|
package/src/runtime-state.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/* WHICH RUNNING THING BACKS WHICH VIEW
|
|
1
|
+
/* WHICH RUNNING THING BACKS WHICH VIEW, the twin of workspace-state.ts, for state that never touches a file.
|
|
2
2
|
*
|
|
3
3
|
* That table answers "the agent wrote a file, so which view is stale". This one answers the same question for
|
|
4
4
|
* the half of the sandbox that has no file to watch: the tmux sessions, the dev-server panels, the listening
|
|
5
5
|
* sockets, the agent's Chromiums, the children its turns spawn. None of them are on disk, so no
|
|
6
|
-
* `workspaceChanged` batch can ever mention them
|
|
6
|
+
* `workspaceChanged` batch can ever mention them, and for want of a push, every one of those views polled.
|
|
7
7
|
*
|
|
8
8
|
* The polls were the tell. Six always-on timers (a 10s terminals relist behind THREE mounted surfaces, a 15s
|
|
9
9
|
* port scan behind the shell rail, a 4s panel relist for as long as any dev server ran) that each asked a
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
* told to. The declaration lives HERE for the reason its twin does: the daemon publishes a DOMAIN and the
|
|
15
15
|
* browser turns it into query keys, and those two facts drift the moment they are written down in two packages.
|
|
16
16
|
*
|
|
17
|
-
* A key belongs to whoever queries it
|
|
17
|
+
* A key belongs to whoever queries it, the extension views that render ports and panels ask under the SAME key
|
|
18
18
|
* as core (api.sandbox.key("ports") is sandboxKey("ports")), so one entry here refreshes both surfaces and an
|
|
19
19
|
* extension needs no declaration of its own. */
|
|
20
20
|
|
|
21
|
-
/* One query key as the browser files it, a segment per level
|
|
21
|
+
/* One query key as the browser files it, a segment per level, so a binding can name a NESTED key and not only
|
|
22
22
|
* a top-level one. Most domains here are a single segment and read as they always did; the review is not. Its
|
|
23
23
|
* key is `["git", "changes"]` (the browser appends the sandbox id, and every open file diff hangs beneath it),
|
|
24
|
-
* and a domain that could only say `"git"` would drop the commit log along with it
|
|
24
|
+
* and a domain that could only say `"git"` would drop the commit log along with it, a second read of the graph
|
|
25
25
|
* to deliver a sentence that has nothing to do with it. */
|
|
26
26
|
export type QueryKeyPath = readonly string[];
|
|
27
27
|
|
|
@@ -29,24 +29,24 @@ export interface RuntimeDomainBinding {
|
|
|
29
29
|
// What moved, in the daemon's words. Wire data: it rides the runtimeChanged frame.
|
|
30
30
|
readonly domain: string;
|
|
31
31
|
/* The browser query keys this domain's state feeds. Invalidation reaches only queries something is
|
|
32
|
-
* OBSERVING, so a domain nobody has on screen costs a frame and no request
|
|
32
|
+
* OBSERVING, so a domain nobody has on screen costs a frame and no request, which is what lets a domain be
|
|
33
33
|
* pushed eagerly without billing every tab for a view it isn't showing. */
|
|
34
34
|
readonly invalidates: readonly QueryKeyPath[];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/* Declared `as const` so the domain names survive as literal types (see RuntimeDomain below), then published
|
|
38
|
-
* under the interface
|
|
38
|
+
* under the interface, the same two-binding shape, for the same reason, as WORKSPACE_STATE_FILES. */
|
|
39
39
|
const RUNTIME_DOMAINS = [
|
|
40
40
|
/* Every attachable tmux session: the terminal panel's tab strip, the rail's activity badge, and the work
|
|
41
|
-
* popover's background-process rows all read this one list. Sampled rather than announced
|
|
42
|
-
* when its command exits and tmux tells nobody
|
|
41
|
+
* popover's background-process rows all read this one list. Sampled rather than announced, a pane dies
|
|
42
|
+
* when its command exits and tmux tells nobody, but sampled ONCE in the daemon, on a connection the
|
|
43
43
|
* browsers already hold, instead of once per browser per 10s over the tunnel. */
|
|
44
44
|
{ domain: "terminals", invalidates: [["terminals"]] },
|
|
45
45
|
|
|
46
46
|
/* A repo's dev server: running, healthy, and the preview URL it answers on. Two independent things move it,
|
|
47
|
-
* and it needs both
|
|
47
|
+
* and it needs both, the process manager starting or reaping a session (announced), and the server
|
|
48
48
|
* actually binding its port some seconds later, which is what flips "starting" to "healthy" (seen by the
|
|
49
|
-
* port sampler, since panel health is read off the listening sockets
|
|
49
|
+
* port sampler, since panel health is read off the listening sockets, see panels.ts listenersByRepo).
|
|
50
50
|
*
|
|
51
51
|
* `apps` rides it because a monorepo's per-app previews ARE managed processes under the same manager
|
|
52
52
|
* (workspace.routes appsList reads processes.portOf), just listed per repo instead of per repo-root. One
|
|
@@ -54,13 +54,13 @@ const RUNTIME_DOMAINS = [
|
|
|
54
54
|
{ domain: "panels", invalidates: [["panels"], ["apps"]] },
|
|
55
55
|
|
|
56
56
|
/* Every listening TCP socket in the sandbox, and which of them are forwarded to a public preview hostname.
|
|
57
|
-
* The daemon runs no port poller for the ANSWER
|
|
58
|
-
* table, far too much to do on a timer
|
|
57
|
+
* The daemon runs no port poller for the ANSWER, attributing a socket to its process walks every /proc fd
|
|
58
|
+
* table, far too much to do on a timer, so what is sampled is only the LISTEN set out of /proc/net/tcp:
|
|
59
59
|
* two file reads, enough to know that the answer changed and worth nobody's while to compute until a view
|
|
60
60
|
* asks. Change detection and payload are deliberately different sizes here. */
|
|
61
61
|
{ domain: "ports", invalidates: [["ports"]] },
|
|
62
62
|
|
|
63
|
-
// The agent's Chromiums and the pages each holds open
|
|
63
|
+
// The agent's Chromiums and the pages each holds open, daemon-held records, minted from the hooks that see
|
|
64
64
|
// the agent's own browser tool calls, so every change to this roster passes through this process.
|
|
65
65
|
{ domain: "browsers", invalidates: [["browsers"]] },
|
|
66
66
|
|
|
@@ -72,17 +72,17 @@ const RUNTIME_DOMAINS = [
|
|
|
72
72
|
/* The post queue, when the DAEMON moves it rather than the owner. Approving is the owner's own mutation and
|
|
73
73
|
* refetches itself, but everything after that happens while nobody is touching the page: a held post coming
|
|
74
74
|
* due, a Discord send landing, a publish turn writing back what went out. Those are the moments the row on
|
|
75
|
-
* screen stops being true
|
|
75
|
+
* screen stops being true, and this queue is watched precisely because its rows go out in public, so it is
|
|
76
76
|
* the last place to leave someone reading a stale one. */
|
|
77
77
|
{ domain: "drafts", invalidates: [["drafts"]] },
|
|
78
78
|
|
|
79
|
-
/* WHAT A LANDED AGENT'S WORK IS CALLED
|
|
79
|
+
/* WHAT A LANDED AGENT'S WORK IS CALLED, the commit message drafted from the diff the moment that work
|
|
80
80
|
* reaches the main tree (agents/landed-subject.ts), which the review's "From" chip files into the commit box.
|
|
81
81
|
*
|
|
82
82
|
* It needs a push of its own because it arrives LATE, and alone. The review refreshes when a turn ends; the
|
|
83
83
|
* sentence is a model call that STARTS there and answers seconds later, so the refresh the landing itself
|
|
84
84
|
* causes is always too early to carry it. Nothing followed. The message then sat in the daemon, correct and
|
|
85
|
-
* unread, until some unrelated write happened to refresh the panel again
|
|
85
|
+
* unread, until some unrelated write happened to refresh the panel again, and a chip clicked in that window
|
|
86
86
|
* filed nothing at all, which is indistinguishable from the feature having been removed.
|
|
87
87
|
*
|
|
88
88
|
* Neither a file nor a ref, so this is the only feed that could carry it: the entry holding it lives on
|
|
@@ -91,23 +91,23 @@ const RUNTIME_DOMAINS = [
|
|
|
91
91
|
* Being in this table also puts the review on the reconnect re-ask (runtimeBoundQueryKeys), and that is the
|
|
92
92
|
* half of the fix nothing else covers: a publish with no browser connected is dropped, so a landing drafted
|
|
93
93
|
* while the app was closed or the tunnel was down would otherwise stay invisible for as long as the panel's
|
|
94
|
-
* snapshot survived
|
|
94
|
+
* snapshot survived, which, at staleTime Infinity, is until something unrelated moved. It costs one review
|
|
95
95
|
* read per (re)connect, against a chip that files nothing for the rest of the session. */
|
|
96
96
|
{ domain: "landings", invalidates: [["git", "changes"]] },
|
|
97
97
|
] as const satisfies readonly RuntimeDomainBinding[];
|
|
98
98
|
|
|
99
99
|
export const RUNTIME_DOMAIN_BINDINGS: readonly RuntimeDomainBinding[] = RUNTIME_DOMAINS;
|
|
100
100
|
|
|
101
|
-
/* Every domain this table declares, as a type
|
|
101
|
+
/* Every domain this table declares, as a type, so a publish site names one of THESE and nothing else, and a
|
|
102
102
|
* renamed domain is a compile error in the daemon rather than a frame the browser silently routes nowhere. */
|
|
103
103
|
export type RuntimeDomain = (typeof RUNTIME_DOMAINS)[number]["domain"];
|
|
104
104
|
|
|
105
|
-
/* Distinct key paths, in table order
|
|
105
|
+
/* Distinct key paths, in table order, two domains in one frame routinely feed the same view, and a Set cannot
|
|
106
106
|
* see that because each path is its own array. Compared by their segments joined, which is exact: a segment is
|
|
107
107
|
* one identifier from this file, never a caller's string, so there is no separator to collide on. */
|
|
108
108
|
const dedupe = (keys: readonly QueryKeyPath[]): readonly QueryKeyPath[] => [...new Map(keys.map((key) => [key.join(`/`), key])).values()];
|
|
109
109
|
|
|
110
|
-
/* The query keys a pushed set of domains makes stale, deduped and stable
|
|
110
|
+
/* The query keys a pushed set of domains makes stale, deduped and stable, the browser's `/events` handler
|
|
111
111
|
* calls this, exactly as it calls staleQueryKeys for a path batch. Kept here rather than in the web so the rule
|
|
112
112
|
* is unit-testable without a query client, and so the daemon can assert against the same table.
|
|
113
113
|
*
|
|
@@ -116,7 +116,7 @@ const dedupe = (keys: readonly QueryKeyPath[]): readonly QueryKeyPath[] => [...n
|
|
|
116
116
|
export const staleRuntimeQueryKeys = (domains: readonly string[]): readonly QueryKeyPath[] =>
|
|
117
117
|
dedupe(RUNTIME_DOMAIN_BINDINGS.filter((binding) => domains.includes(binding.domain)).flatMap((binding) => binding.invalidates));
|
|
118
118
|
|
|
119
|
-
/* Every query key any runtime domain feeds
|
|
119
|
+
/* Every query key any runtime domain feeds, what a NEW /events connection re-asks wholesale, for precisely the
|
|
120
120
|
* reason fileBoundQueryKeys exists: this push is these views' ONLY live feed, and a frame produced while the
|
|
121
121
|
* stream was down is a frame nobody will resend. A panel that finished starting, a session that exited, a port
|
|
122
122
|
* that closed while the browser was away would otherwise sit wrong until the next unrelated change. Re-asking
|