@intentic/sandbox-contract 1.156.0 → 1.158.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.
@@ -0,0 +1,124 @@
1
+ /* HOW A MODEL CATALOG IS ORDERED — one rule for every provider, because only one provider publishes an order
2
+ * worth keeping.
3
+ *
4
+ * Anthropic's REST /v1/models answers newest-first: that IS a provider opinion, and Claude's catalog rides it
5
+ * (claude-models.ts). Every other provider here is read through an OpenAI-compatible /v1/models — Codex and
6
+ * Gemini via the bundled translator, Kimi via Moonshot — or out of xAI's "Did you mean" rejection, and those
7
+ * endpoints publish a SET, not a ranking: they hand the ids back in whatever order their registry iterates,
8
+ * which in practice is alphabetical. Reading that as a preference is what put "GPT 5.4 Mini" at the head of the
9
+ * Codex group with GPT 5.6 below it, and what made a fresh Codex conversation start on whichever id happened to
10
+ * sort first — models[0] is the provider default.
11
+ *
12
+ * So for those providers the order is DERIVED from the id, out of the only two facts an id reliably carries:
13
+ * which TIER the model is (the adjective) and which RELEASE it is (the numbers). Both are provider-agnostic —
14
+ * every vendor names its models the same way — which is what lets the daemon's four catalog services and the
15
+ * web's picker share one rule instead of each inventing a local one. */
16
+
17
+ // A version-ish segment: digits and dots, optionally v-prefixed (`4`, `5.1`, `v2`, `20251001`). Everything else
18
+ // is a NAME segment and belongs to the family — which is what makes the split below exhaustive.
19
+ const VERSION_SEGMENT = /^v?[\d.]+$/;
20
+
21
+ // A date stamp rather than a version component: six digits or more (20251001, 250514). The distinction is not
22
+ // cosmetic — claude-opus-4-1-20250805 (Opus 4.1) and claude-opus-4-20250514 (Opus 4.0) compare as (4,1) vs (4)
23
+ // with the stamps held apart, and as (4,1,20250805) vs (4,20250514) — the OLDER model winning — without.
24
+ const DATE_SEGMENT = /^\d{6,}$/;
25
+
26
+ const segmentsOf = (id: string): string[] => id.split(/[-_]/);
27
+
28
+ // A model's FAMILY — its id with every version-ish segment dropped, so claude-opus-5 and claude-opus-4-8 land
29
+ // together (as do gpt-5.1/gpt-5, and claude-haiku-4-5-20251001 with its date suffix). Derived, never listed: a
30
+ // family that ships tomorrow groups itself. The id is the stable key here — labels get renamed, ids don't.
31
+ export const familyOf = (id: string): string => {
32
+ const stem = segmentsOf(id)
33
+ .filter((segment) => !VERSION_SEGMENT.test(segment))
34
+ .join("-");
35
+ // An all-numeric id (and an ACP row's empty one) has no stem to speak of; it stands as its own family.
36
+ return stem === "" ? id : stem;
37
+ };
38
+
39
+ export interface ModelRelease {
40
+ // The version components in id order: gpt-5.1 → [5, 1], claude-opus-4-8 → [4, 8]. EMPTY for an unversioned
41
+ // id (kimi-latest, gemini-pro-agent), which therefore reads as the oldest of its tier: a rolling alias names
42
+ // no release, and inventing one for it would outrank the models that do name theirs.
43
+ readonly version: readonly number[];
44
+ // The id's date stamp, 0 for none — the tiebreak between two builds of the SAME version.
45
+ readonly date: number;
46
+ }
47
+
48
+ export const releaseOf = (id: string): ModelRelease => {
49
+ const numeric = segmentsOf(id)
50
+ .filter((segment) => VERSION_SEGMENT.test(segment))
51
+ .map((segment) => segment.replace(/^v/, ""));
52
+ const stamps = numeric.filter((segment) => DATE_SEGMENT.test(segment)).map(Number);
53
+ return {
54
+ version: numeric
55
+ .filter((segment) => !DATE_SEGMENT.test(segment))
56
+ .flatMap((segment) => segment.split(".").map(Number))
57
+ .filter((component) => Number.isFinite(component)),
58
+ date: Math.max(0, ...stamps),
59
+ };
60
+ };
61
+
62
+ // Newest first. A missing component reads as -1, so gpt-5 sorts under gpt-5.1 and an unversioned id sorts under
63
+ // every versioned one; the date stamp breaks what is left.
64
+ const compareRelease = (left: ModelRelease, right: ModelRelease): number => {
65
+ for (let index = 0; index < Math.max(left.version.length, right.version.length); index += 1) {
66
+ const diff = (right.version[index] ?? -1) - (left.version[index] ?? -1);
67
+ if (diff !== 0) {
68
+ return diff;
69
+ }
70
+ }
71
+ return right.date - left.date;
72
+ };
73
+
74
+ /* THE ONE CURATED FACT in this file, and the only one the providers publish nowhere the app can read: which tier
75
+ * is the frontier and which is the cheap one. It ranks FAMILIES, never models, and it is a vocabulary of tier
76
+ * ADJECTIVES rather than a table of ids — that scoping is the whole point, because a per-model ranking table
77
+ * failed here once already. The words are the ones every vendor reaches for, so a release that ships tomorrow
78
+ * ranks itself as long as it is named like its predecessors, and a release named some other way ranks as unknown.
79
+ *
80
+ * An UNKNOWN family LEADS rather than sinks, and that direction is the point: the ranking this replaced sank
81
+ * unrecognized ids to a floor below the everyday tier, so a brand-new flagship sorted beneath the model it
82
+ * replaced. An id carrying no tier word at all is the provider's BASE line (gpt-5.6, grok-4, kimi-k2) — which is
83
+ * exactly the line a user reaches for — and a family nobody here has heard of is far likelier to be the next
84
+ * flagship than the next budget tier. Being wrong costs one row's position; being wrong the other way hides a
85
+ * launch. */
86
+ const TIER_RANK: Readonly<Record<string, number>> = {
87
+ // Frontier: the tier a vendor ships last and charges most for.
88
+ opus: 0,
89
+ fable: 0,
90
+ pro: 0,
91
+ max: 0,
92
+ ultra: 0,
93
+ heavy: 0,
94
+ // Everyday: the workhorse a step below the frontier.
95
+ sonnet: 1,
96
+ flash: 1,
97
+ mini: 1,
98
+ // Efficient: the cheap/fast end, the rung whose whole purpose is to cost less than the one above it.
99
+ haiku: 2,
100
+ lite: 2,
101
+ nano: 2,
102
+ fast: 2,
103
+ small: 2,
104
+ };
105
+
106
+ const UNRANKED = -1;
107
+
108
+ // The LAST recognized word wins, because tier words compose and the rightmost is the most specific one:
109
+ // gemini-flash-lite is the cheap end of Flash, gpt-codex-max the frontier end of Codex.
110
+ export const tierRankOf = (family: string): number => {
111
+ let rank = UNRANKED;
112
+ for (const segment of family.split("-")) {
113
+ const found = TIER_RANK[segment];
114
+ if (found !== undefined) {
115
+ rank = found;
116
+ }
117
+ }
118
+ return rank;
119
+ };
120
+
121
+ // The canonical order of two model ids: tier first, then release. Hand it straight to Array#toSorted — that sort
122
+ // is stable, so two ids this rule cannot separate keep the order they arrived in (for Claude, the provider's own).
123
+ export const compareModelIds = (left: string, right: string): number =>
124
+ tierRankOf(familyOf(left)) - tierRankOf(familyOf(right)) || compareRelease(releaseOf(left), releaseOf(right));
package/src/schemas.ts CHANGED
@@ -206,10 +206,13 @@ export const AgentIdSchema = z.object({ id: z.string().min(1) });
206
206
  // right now (the lane header's "Clear"); unarchive always names its ids (a restore, or a bulk archive's undo).
207
207
  export const AgentArchiveSchema = z.object({ ids: z.array(z.string().min(1)).max(500).optional() });
208
208
  export const AgentIdsSchema = z.object({ ids: z.array(z.string().min(1)).min(1).max(500) });
209
- // The roster after the change PLUS the ids that actually moved the board needs those to offer "Undo",
210
- // since "archive everything finished" can't be inverted by re-reading the list afterwards.
211
- export const AgentArchiveResultSchema = z.object({ agents: z.array(AgentSummarySchema), archived: z.array(z.string()) });
212
- export type AgentArchiveResult = z.infer<typeof AgentArchiveResultSchema>;
209
+ // What actually MOVED, and deliberately NOT the roster afterwards. Two archives in flight at once each finish
210
+ // holding a full-roster snapshot from a different instant, so a client that swapped one in wholesale would let
211
+ // the slower response resurrect what the faster one just filed away — a delta composes where a snapshot races.
212
+ // Whole summaries rather than ids because the receiving side has to SHOW them (the archive list, and the agent
213
+ // detail page addressed by id); the ids "Undo" needs come off them for free.
214
+ export const AgentsMovedSchema = z.object({ moved: z.array(AgentSummarySchema) });
215
+ export type AgentsMoved = z.infer<typeof AgentsMovedSchema>;
213
216
  // rename's input: the user-chosen display title (bounded like sanitizeTitle's cap).
214
217
  export const AgentRenameSchema = z.object({ id: z.string().min(1), title: z.string().trim().min(1).max(80) });
215
218
  export const AgentFileDiffQuerySchema = z.object({ id: z.string().min(1), repo: z.string().min(1), path: z.string().min(1) });
@@ -273,8 +276,20 @@ export const AgentReplySchema = z.discriminatedUnion("kind", [
273
276
  export type AgentReply = z.infer<typeof AgentReplySchema>;
274
277
  // Steering: a user message delivered INTO the running turn (injected between tool calls, Claude Code style),
275
278
  // keyed by the conversation whose turn is in flight. NOT_FOUND when no steerable turn is running — the client
276
- // then falls back to a fresh send.
277
- export const SteerSchema = z.object({ conversationId: z.string().min(1), text: z.string().min(1).max(20_000) });
279
+ // then holds the message in its queue and sends it as the next turn instead. Carries everything a turn's own
280
+ // prompt can carry (files, the editor-context chip), because "add more while it works" is worth nothing if it
281
+ // only takes bare text: the daemon folds the same notes into the injected message that a fresh turn gets.
282
+ export const SteerSchema = z
283
+ .object({
284
+ conversationId: z.string().min(1),
285
+ text: z.string().max(20_000),
286
+ attachments: z.array(z.string().min(1)).max(20).optional(),
287
+ editorContext: EditorContextSchema.optional(),
288
+ })
289
+ // An attachment-only steer (a screenshot dropped in mid-turn) is legal; an entirely empty one is not.
290
+ .refine((steer) => steer.text.trim().length > 0 || (steer.attachments?.length ?? 0) > 0, {
291
+ message: "text or attachments required",
292
+ });
278
293
  // True cancel for the conversation's in-flight turn — aborts the agent daemon-side, unlike closing the
279
294
  // /agent fetch (which sends no cancel frame).
280
295
  export const StopTurnSchema = z.object({ conversationId: z.string().min(1) });
@@ -1447,6 +1462,42 @@ export type ExtensionProcessStatus = z.infer<typeof ExtensionProcessStatusSchema
1447
1462
  // /webchat/<id>/message and the agent's reply streams back over SSE. Its address is the public automation id, so
1448
1463
  // allowedOrigins (the widget's embed sites) + a per-conversation rate limit are its abuse boundary — no secret
1449
1464
  // token can live in a browser.
1465
+ // `workspace` fires from the sandbox's OWN codebase instead of the outside world — see WorkspaceEventKindSchema.
1466
+
1467
+ // What the daemon emits as the fleet works, and what a `workspace` trigger names. These are the events a code
1468
+ // CHORE runs on (continuous review, post-land checks): the daemon is both producer and consumer, so unlike
1469
+ // `event` there is no token and no route — nothing outside the sandbox can reach them.
1470
+ //
1471
+ // The two OVERLAP on the common path: a clean turn auto-lands, firing both. A chore should name exactly one.
1472
+ // `turn.settled` fires once per isolated turn whatever its outcome, so it also covers the errored and
1473
+ // conflicted turns most worth a second pair of eyes, and it fires while the user is still looking at the diff —
1474
+ // before they decide to land. `agent.landed` fires only when work actually reached the main tree, including an
1475
+ // explicit Land from the review panel long after the turn ended.
1476
+ export const WorkspaceEventKindSchema = z.enum(["turn.settled", "agent.landed"]);
1477
+ export type WorkspaceEventKind = z.infer<typeof WorkspaceEventKindSchema>;
1478
+
1479
+ // The payload a workspace-triggered wake carries: one JSON object, in $AUTOMATION_PAYLOAD for the guard and
1480
+ // appended to the prompt for the turn.
1481
+ //
1482
+ // `repos` names the change to look at as an OPEN span — `git -C <dir> diff <from>`, with no upper bound. Each
1483
+ // `from` is where that repo stood before the turn (its last landed tip, or the base it branched from); the
1484
+ // other end is deliberately the working tree rather than a sha, because a turn that ERRORED left its work
1485
+ // uncommitted in the worktree and a commit-to-commit span would report it as nothing at all. `dir` is that
1486
+ // repo's dir inside the agent's own checkout, so a chore reads the agent's work without touching /work.
1487
+ //
1488
+ // No diffstat rides along on purpose: the registry's counts are refreshed at land, so an errored or conflicted
1489
+ // turn would carry stale numbers, and a guard that wants a size threshold gets the true one from
1490
+ // `git -C <dir> diff --numstat <from>` for the price of one spawn.
1491
+ export const WorkspaceEventSchema = z.object({
1492
+ event: WorkspaceEventKindSchema,
1493
+ agentId: z.string(),
1494
+ title: z.string().optional(),
1495
+ branch: z.string(),
1496
+ outcome: z.enum(["landed", "conflict", "idle", "error"]),
1497
+ repos: z.array(z.object({ repo: z.string(), from: z.string(), dir: z.string() })),
1498
+ });
1499
+ export type WorkspaceEvent = z.infer<typeof WorkspaceEventSchema>;
1500
+
1450
1501
  export const TriggerSchema = z.discriminatedUnion("kind", [
1451
1502
  z.object({ kind: z.literal("schedule"), cron: z.string().min(1) }),
1452
1503
  z.object({ kind: z.literal("event"), token: z.string().min(1).optional() }),
@@ -1459,6 +1510,8 @@ export const TriggerSchema = z.discriminatedUnion("kind", [
1459
1510
  // webchat only: the website origins allowed to POST to the widget endpoint. Absent/empty ⇒ none admitted.
1460
1511
  allowedOrigins: z.array(z.string()).optional(),
1461
1512
  }),
1513
+ // `repo` narrows to events whose span touches one workspace repo ("root" or a repo id); absent ⇒ any.
1514
+ z.object({ kind: z.literal("workspace"), event: WorkspaceEventKindSchema, repo: z.string().min(1).optional() }),
1462
1515
  ]);
1463
1516
  export type Trigger = z.infer<typeof TriggerSchema>;
1464
1517
 
@@ -1476,6 +1529,11 @@ export const AutomationSchema = z.object({
1476
1529
  model: z.string().optional(),
1477
1530
  // When true, a fire doesn't wake the agent — it's held in the approvals queue until the owner approves.
1478
1531
  requireApproval: z.boolean().optional(),
1532
+ // A code CHORE: maintenance of THIS codebase rather than a reaction to the outside world. Purely a
1533
+ // classification — the daemon fires a chore exactly like any other automation — but it cannot be derived
1534
+ // from the trigger, which is why it is stored: a nightly `pnpm audit` sweep and a nightly Stripe poll are
1535
+ // both `schedule`, and belong on different shelves. Absent ⇒ an ordinary automation.
1536
+ chore: z.boolean().optional(),
1479
1537
  enabled: z.boolean(),
1480
1538
  });
1481
1539
  export type Automation = z.infer<typeof AutomationSchema>;