@intentic/sandbox-contract 1.223.0 → 1.225.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 +14 -14
- package/dist/agent-catalog.js +3 -3
- package/dist/agent-catalog.js.map +1 -1
- package/dist/chores/chores.js +43 -43
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/extension-update.js +2 -2
- package/dist/chores/extension-update.js.map +1 -1
- package/dist/chores/fix-deps.js +1 -1
- package/dist/chores/fix-deps.js.map +1 -1
- package/dist/chores/probes.js +1 -1
- package/dist/chores/probes.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/chores/verdict.js +2 -2
- package/dist/chores/verdict.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +41 -0
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/exit.contract.d.ts +80 -0
- package/dist/contracts/exit.contract.d.ts.map +1 -0
- package/dist/contracts/exit.contract.js +13 -0
- package/dist/contracts/exit.contract.js.map +1 -0
- package/dist/contracts/ports.contract.d.ts +11 -0
- package/dist/contracts/ports.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +12 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/events.d.ts +4 -4
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +5 -5
- package/dist/events.js.map +1 -1
- package/dist/fast-tier.d.ts +9 -0
- package/dist/fast-tier.d.ts.map +1 -0
- package/dist/fast-tier.js +19 -0
- package/dist/fast-tier.js.map +1 -0
- package/dist/history-state.js +3 -3
- package/dist/history-state.js.map +1 -1
- package/dist/index.d.ts +214 -68
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/model-order.d.ts +1 -0
- package/dist/model-order.d.ts.map +1 -1
- package/dist/model-order.js +5 -0
- package/dist/model-order.js.map +1 -1
- package/dist/output-fields.d.ts.map +1 -1
- package/dist/output-fields.js +2 -2
- package/dist/output-fields.js.map +1 -1
- package/dist/prompt-complexity.d.ts +19 -0
- package/dist/prompt-complexity.d.ts.map +1 -0
- package/dist/prompt-complexity.js +83 -0
- package/dist/prompt-complexity.js.map +1 -0
- package/dist/publish-drafts.js +2 -2
- package/dist/publish-drafts.js.map +1 -1
- package/dist/schemas.d.ts +236 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +116 -2
- package/dist/schemas.js.map +1 -1
- package/dist/workflow-faults.js +3 -3
- package/dist/workflow-faults.js.map +1 -1
- package/dist/workspace-state.d.ts +20 -20
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +20 -20
- package/dist/workspace-state.js.map +1 -1
- package/package.json +5 -5
- package/src/agent-catalog.test.ts +25 -25
- package/src/agent-catalog.ts +81 -81
- package/src/agent-run-model.test.ts +3 -3
- package/src/agent-run-model.ts +8 -8
- package/src/capability-env.ts +3 -3
- package/src/capability-ledger.test.ts +13 -13
- package/src/capability-secrets.ts +5 -5
- package/src/chores/chores.test.ts +1 -1
- package/src/chores/chores.ts +109 -109
- package/src/chores/digest.test.ts +1 -1
- package/src/chores/digest.ts +3 -3
- package/src/chores/extension-update.ts +5 -5
- package/src/chores/fix-deps.ts +4 -4
- package/src/chores/probes.test.ts +6 -6
- package/src/chores/probes.ts +17 -17
- package/src/chores/prompt.ts +9 -9
- package/src/chores/stack.test.ts +3 -3
- package/src/chores/stack.ts +17 -17
- package/src/chores/verdict.test.ts +20 -20
- package/src/chores/verdict.ts +25 -25
- package/src/contract-lock.test.ts +1 -1
- 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/exit.contract.ts +42 -0
- 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 +8 -8
- package/src/events.test.ts +3 -3
- package/src/events.ts +142 -142
- package/src/fast-tier.test.ts +88 -0
- package/src/fast-tier.ts +72 -0
- package/src/history-state.ts +15 -15
- package/src/host-protocol.ts +7 -7
- package/src/hostnames.test.ts +1 -1
- package/src/hostnames.ts +15 -15
- package/src/index.ts +13 -8
- package/src/listener-protocol.ts +12 -12
- package/src/model-order.test.ts +11 -11
- package/src/model-order.ts +52 -30
- package/src/output-fields.ts +11 -11
- package/src/path-refs.test.ts +4 -4
- package/src/path-refs.ts +6 -6
- package/src/prompt-complexity.test.ts +160 -0
- package/src/prompt-complexity.ts +271 -0
- package/src/publish-drafts.ts +8 -8
- package/src/quick-model.test.ts +11 -11
- package/src/quick-model.ts +15 -15
- package/src/routes.test.ts +11 -5
- package/src/routes.ts +19 -19
- package/src/runtime-state.test.ts +1 -1
- package/src/runtime-state.ts +22 -22
- package/src/schemas.test.ts +8 -8
- package/src/schemas.ts +1238 -921
- package/src/search-globs.test.ts +2 -2
- package/src/search-globs.ts +6 -6
- package/src/session-names.ts +5 -5
- package/src/share-paths.test.ts +1 -1
- 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.test.ts +9 -9
- package/src/title.ts +21 -21
- package/src/tunnel-ids.test.ts +3 -3
- package/src/tunnel-ids.ts +7 -7
- package/src/versions.test.ts +3 -3
- package/src/versions.ts +6 -6
- package/src/workflow-faults.test.ts +6 -6
- package/src/workflow-faults.ts +14 -14
- package/src/workspace-state.test.ts +32 -32
- package/src/workspace-state.ts +139 -139
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { fastTierModel } from "./fast-tier.js";
|
|
3
|
+
|
|
4
|
+
/* WHERE A DOWNGRADED TURN ACTUALLY LANDS. The judge (prompt-complexity.ts) says a turn could be cheaper; this
|
|
5
|
+
* says whether there is anywhere cheaper to put it, on the provider it is already on.
|
|
6
|
+
*
|
|
7
|
+
* Two properties carry the whole feature, and everything below is one of them: a downgrade is only ever to a
|
|
8
|
+
* genuinely CHEAPER rung of the same catalog (so "cheaper" can never quietly become "older", or "the same model
|
|
9
|
+
* with less thinking"), and it never crosses PROVIDER (because that retires the conversation's session, which
|
|
10
|
+
* throws away the context that made the follow-up cheap to answer in the first place). */
|
|
11
|
+
|
|
12
|
+
const CLAUDE = [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`];
|
|
13
|
+
const GOOGLE = [`gemini-3-pro`, `gemini-3-flash`, `gemini-3-flash-lite`];
|
|
14
|
+
|
|
15
|
+
const fastFor = (model: string, over: { models?: readonly string[]; pinned?: readonly string[]; provider?: string } = {}) =>
|
|
16
|
+
fastTierModel({ provider: over.provider ?? `claude`, model, models: over.models ?? CLAUDE, pinned: over.pinned ?? [] });
|
|
17
|
+
|
|
18
|
+
// --- Auto: the cheapest rung the provider publishes ----------------------------------------------------
|
|
19
|
+
|
|
20
|
+
test("sends a frontier turn to the provider's own cheap rung", () => {
|
|
21
|
+
expect(fastFor(`claude-opus-5`)).toBe(`claude-haiku-4-5-20251001`);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("reads the cheap end the same way the quick model does, on every vendor's vocabulary", () => {
|
|
25
|
+
expect(fastFor(`gemini-3-pro`, { provider: `gemini`, models: GOOGLE })).toBe(`gemini-3-flash-lite`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// --- the ceiling: the user's own pick -------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
test("a user already on the cheap rung has nowhere to be sent", () => {
|
|
31
|
+
// The common case worth being exact about rather than an edge case. Undefined means "run what they asked
|
|
32
|
+
// for", which is the honest answer and the one that costs nothing to be wrong about.
|
|
33
|
+
expect(fastFor(`claude-haiku-4-5-20251001`)).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("never swaps a model for an older build of the same tier", () => {
|
|
37
|
+
// A downgrade has to be legible AS a downgrade. Last year's Sonnet under this year's Sonnet is not a
|
|
38
|
+
// saving the feature promised, it is a different turn wearing the user's own model name.
|
|
39
|
+
expect(fastFor(`claude-sonnet-5`, { models: [`claude-sonnet-5`, `claude-sonnet-4`] })).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("never downgrades a model whose family this build does not recognise", () => {
|
|
43
|
+
// An id carrying no tier word is a provider's base line or a family nobody here has heard of, and betting
|
|
44
|
+
// a user's turn on the guess that an unknown id is the budget one is the wrong direction to be wrong in.
|
|
45
|
+
expect(fastFor(`claude-opus-5`, { models: [`some-new-thing`] })).toBeUndefined();
|
|
46
|
+
expect(fastFor(`some-new-thing`, { models: CLAUDE })).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("an unloaded catalog resolves to no downgrade rather than to a guess", () => {
|
|
50
|
+
expect(fastFor(`claude-opus-5`, { models: [] })).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no pick yet means nothing to be cheaper than", () => {
|
|
54
|
+
expect(fastFor(``)).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// --- pins -----------------------------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
test("a pin on this provider wins over the catalog's own cheap end", () => {
|
|
60
|
+
expect(fastFor(`claude-opus-5`, { pinned: [`claude:claude-sonnet-5`] })).toBe(`claude-sonnet-5`);
|
|
61
|
+
});
|
|
62
|
+
|
|
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 resolveQuickModels makes, and for the same reason: the picker offers a custom-id escape
|
|
65
|
+
// hatch, and second-guessing the id here would run a different model than the settings row names.
|
|
66
|
+
expect(fastFor(`claude-opus-5`, { pinned: [`claude:claude-haiku-9`] })).toBe(`claude-haiku-9`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("drops a pin naming another provider rather than crossing to it", () => {
|
|
70
|
+
// Switching provider retires the conversation's session (turnRequest.ts `resumes`). Starting the
|
|
71
|
+
// conversation over to save a fraction of a cent is not a saving, so the pin is ignored and Auto answers.
|
|
72
|
+
expect(fastFor(`claude-opus-5`, { pinned: [`gemini:gemini-3-flash-lite`] })).toBe(`claude-haiku-4-5-20251001`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("skips a pin that is not actually cheaper than what the user picked", () => {
|
|
76
|
+
// A pin is a preference about WHICH cheap rung, never a licence to swap a model for its equal or better.
|
|
77
|
+
expect(fastFor(`claude-sonnet-5`, { pinned: [`claude:claude-opus-5`] })).toBe(`claude-haiku-4-5-20251001`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("walks past an unusable pin to the next one that names this provider", () => {
|
|
81
|
+
const pinned = [`gemini:gemini-3-flash`, `claude:claude-opus-5`, `claude:claude-haiku-4-5`];
|
|
82
|
+
|
|
83
|
+
expect(fastFor(`claude-opus-5`, { pinned })).toBe(`claude-haiku-4-5`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("falls back to Auto when no pin survives, rather than to no downgrade at all", () => {
|
|
87
|
+
expect(fastFor(`claude-opus-5`, { pinned: [`nonsense`, `gemini:gemini-3-flash`] })).toBe(`claude-haiku-4-5-20251001`);
|
|
88
|
+
});
|
package/src/fast-tier.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { compareCheapestFirst, isCheaperRung } from "./model-order.js";
|
|
2
|
+
import { parsePinned } from "./quick-model.js";
|
|
3
|
+
import type { AgentProvider } from "./schemas.js";
|
|
4
|
+
|
|
5
|
+
/* WHICH MODEL A DOWNGRADED TURN ACTUALLY RUNS ON, the second half of automatic tier selection.
|
|
6
|
+
* prompt-complexity.ts decides a turn COULD be cheaper; this decides whether there is anything cheaper to put
|
|
7
|
+
* it on, and names it.
|
|
8
|
+
*
|
|
9
|
+
* THERE IS NO "STANDARD TIER" SETTING, and its absence is the design rather than an omission. The standard tier
|
|
10
|
+
* is whatever the user already picked for this conversation. So this mechanism can only ever route DOWN, from a
|
|
11
|
+
* model somebody chose to a cheaper rung of the same catalog, which means the worst case of a wrong answer is
|
|
12
|
+
* one turn's quality on a model the user can see and correct, never a bill they did not ask for. Every
|
|
13
|
+
* ambiguous case in this file therefore resolves to `undefined`, which the caller reads as "run what they
|
|
14
|
+
* asked for".
|
|
15
|
+
*
|
|
16
|
+
* THE PROVIDER IS NEVER CROSSED, and this is the constraint the rest of the app imposes rather than one this
|
|
17
|
+
* file would have chosen. A conversation's provider session is resumed only while the selection still matches
|
|
18
|
+
* the runtime and account that minted it (turnRequest.ts `resumes`, which compares provider, account and
|
|
19
|
+
* harness, and pointedly NOT model). A model swap inside one provider is free, and the session carries on. A
|
|
20
|
+
* provider swap RETIRES the session and cuts a new segment, which throws away the very context that made the
|
|
21
|
+
* follow-up cheap to answer. So a cross-provider pin here is dropped rather than honoured: saving a fraction of
|
|
22
|
+
* a cent by starting the conversation over is not a saving.
|
|
23
|
+
*
|
|
24
|
+
* THE ORDER IS A LADDER, for the reason quick-model.ts is one, but a shorter one: the caller spends the head
|
|
25
|
+
* and falls back to the user's own pick, rather than walking rungs. A downgrade that cannot be started is not
|
|
26
|
+
* worth a second attempt when the honest answer (their model) is sitting right there. */
|
|
27
|
+
|
|
28
|
+
export interface FastTierInput {
|
|
29
|
+
// The provider this turn is on. Both the pick and every candidate belong to it; see above.
|
|
30
|
+
readonly provider: AgentProvider;
|
|
31
|
+
// The model the user picked, i.e. the standard tier and the ceiling. Empty when the composer has not
|
|
32
|
+
// resolved one yet, which resolves to no downgrade: there is nothing to be cheaper THAN.
|
|
33
|
+
readonly model: string;
|
|
34
|
+
// That provider's catalog as the caller can see it. Empty is a real state (a catalog that has not loaded),
|
|
35
|
+
// and it resolves to no downgrade rather than to a guess.
|
|
36
|
+
readonly models: readonly string[];
|
|
37
|
+
// settings.autoFastModels: an ordered list of `${provider}:${modelId}` keys (quickModelKey), or empty for
|
|
38
|
+
// Auto. Entries naming another provider are dropped, not honoured.
|
|
39
|
+
readonly pinned: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* THE CHEAPER MODEL TO RUN THIS TURN ON, or undefined for "there isn't one, use their pick".
|
|
43
|
+
*
|
|
44
|
+
* A PIN IS TAKEN VERBATIM against the catalog, the same call resolveQuickModels makes and for the same reason:
|
|
45
|
+
* the model picker offers a custom-id escape hatch for a model the static catalog has not caught up with, and
|
|
46
|
+
* second-guessing the id here would run a different model than the settings row names. It is still checked for
|
|
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
|
|
48
|
+
* cheaper than the pick is not a downgrade at all.
|
|
49
|
+
*
|
|
50
|
+
* AUTO IS THE CHEAPEST ROW THE PROVIDER PUBLISHES, read through the same cheap-end order the quick model uses,
|
|
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 quickModel's empty default does.
|
|
53
|
+
*
|
|
54
|
+
* NOTHING CHEAPER THAN THE PICK ⇒ UNDEFINED, and that is the common case worth being exact about rather than
|
|
55
|
+
* the edge case: a user already working on the cheap rung has nowhere to be sent, and a user on a model whose
|
|
56
|
+
* family this build does not recognize is not going to be downgraded on a guess (isCheaperRung). */
|
|
57
|
+
export const fastTierModel = (input: FastTierInput): string | undefined => {
|
|
58
|
+
if (input.model === "") {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const pinned = input.pinned
|
|
62
|
+
.flatMap((key) => {
|
|
63
|
+
const choice = parsePinned(key);
|
|
64
|
+
return choice === undefined || choice.provider !== input.provider ? [] : [choice.model];
|
|
65
|
+
})
|
|
66
|
+
.find((model) => isCheaperRung(model, input.model));
|
|
67
|
+
if (pinned !== undefined) {
|
|
68
|
+
return pinned;
|
|
69
|
+
}
|
|
70
|
+
const cheapest = input.models.toSorted(compareCheapestFirst)[0];
|
|
71
|
+
return cheapest !== undefined && isCheaperRung(cheapest, input.model) ? cheapest : undefined;
|
|
72
|
+
};
|
package/src/history-state.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { StateFile } from "./state-portability.js";
|
|
2
2
|
|
|
3
|
-
/* WHAT LIVES ON /history
|
|
3
|
+
/* WHAT LIVES ON /history, the second half of the daemon's state, and the half nothing declared until an
|
|
4
4
|
* export had to reason about it.
|
|
5
5
|
*
|
|
6
6
|
* `WORKSPACE_STATE_FILES` covers `<workspace>/.intentic/`, which is where the manifests live. It is not where
|
|
7
|
-
* the machinery lives. Every repo's REAL git dir is here (a repo's in-tree `.git` is a pointer file
|
|
7
|
+
* the machinery lives. Every repo's REAL git dir is here (a repo's in-tree `.git` is a pointer file, see
|
|
8
8
|
* git/repo-git-dirs.ts for the invariant that forces it), and so are the fleet registry, the turn journal, the
|
|
9
9
|
* ledgers, the checkpoint scopes and the isolated agents' checkouts. A "workspace export" that took `/work`
|
|
10
|
-
* alone would carry a tree of repos with dangling gitdir pointers
|
|
11
|
-
* answering `fatal: not a git repository
|
|
10
|
+
* alone would carry a tree of repos with dangling gitdir pointers, every git command in the restored sandbox
|
|
11
|
+
* answering `fatal: not a git repository`, and an empty agent board.
|
|
12
12
|
*
|
|
13
13
|
* The two tables stay separate rather than becoming one keyed by volume, because they answer different
|
|
14
14
|
* questions. A `.intentic` entry also declares which browser QUERY it makes stale, since the file watcher
|
|
@@ -17,7 +17,7 @@ import type { StateFile } from "./state-portability.js";
|
|
|
17
17
|
* imported rather than duplicated.
|
|
18
18
|
*
|
|
19
19
|
* `history-state-coverage.test.ts` fails when a daemon store builds a `/history` path this list doesn't carry, in both
|
|
20
|
-
* directions
|
|
20
|
+
* directions, the same shape-recognizing guard that covers the workspace table.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
// Paths are historyRoot-relative, forward-slash, matched by PREFIX; a directory entry keeps its trailing slash
|
|
@@ -27,7 +27,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
27
27
|
|
|
28
28
|
/* THE ONE THAT MAKES A BUNDLE A WORKSPACE. Every repo's real git dir, including the /work root's own
|
|
29
29
|
* ("root"), keyed by URI-encoded repo id. Carrying the working tree without this hands the target files
|
|
30
|
-
* whose `.git` points at a path that does not exist there
|
|
30
|
+
* whose `.git` points at a path that does not exist there, which is not a degraded repo but a broken one,
|
|
31
31
|
* and it takes the Changes review, the diff, land and every agent branch with it. The agent BRANCHES live
|
|
32
32
|
* in here too, which is what lets the checkouts below be left out. */
|
|
33
33
|
{ path: "gits/", portability: "carry" },
|
|
@@ -38,7 +38,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
38
38
|
{ path: "agents.json", portability: "carry" },
|
|
39
39
|
{ path: "turns/", portability: "carry" },
|
|
40
40
|
{ path: "transcripts/", portability: "carry" },
|
|
41
|
-
// What each message can be put back to
|
|
41
|
+
// What each message can be put back to, a workspace checkpoint, or an isolated conversation's own commits.
|
|
42
42
|
// Carried WITH the transcripts and the scopes above, because it is the join between them: without it a
|
|
43
43
|
// restored conversation reads back whole and offers no way back into it, even though both the messages and
|
|
44
44
|
// the states they name travelled.
|
|
@@ -48,7 +48,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
48
48
|
* Carried, and it is the entry with the most to say for itself: the PAGES live in the workspace's outbox
|
|
49
49
|
* (`public/`), so they travel with `/work` whatever this says. Leaving the index behind would restore a
|
|
50
50
|
* sandbox that is still serving somebody's conversation on the internet with nothing in the app that knows
|
|
51
|
-
* it
|
|
51
|
+
* it, no row, no link, and no way to stop sharing short of deleting files by hand. The index is what makes
|
|
52
52
|
* a published page withdrawable, so it goes wherever the pages go. */
|
|
53
53
|
{ path: "shares.json", portability: "carry" },
|
|
54
54
|
{ path: "activity.jsonl", portability: "carry" },
|
|
@@ -58,7 +58,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
58
58
|
// Explicit first-time dependency setup requests. Carrying the worklist preserves the owner's decision when
|
|
59
59
|
// an export interrupts the queue before its terminal starts; fulfilled entries remove themselves.
|
|
60
60
|
{ path: "dependency-requests.json", portability: "carry" },
|
|
61
|
-
// The deploy engine's own ledgers
|
|
61
|
+
// The deploy engine's own ledgers, a run's events and the check results the Pipelines view reads back.
|
|
62
62
|
{ path: "apply-events.ndjson", portability: "carry" },
|
|
63
63
|
{ path: "check-events/", portability: "carry" },
|
|
64
64
|
|
|
@@ -68,7 +68,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
68
68
|
*
|
|
69
69
|
* A conversation's worktree is a full checkout of the monorepo per agent (plus its overlay upper dir), and
|
|
70
70
|
* there can be a hundred of them. None of it is unique: the branch it holds is in `gits/` above, and the
|
|
71
|
-
* registry entry naming it travels in agents.json
|
|
71
|
+
* registry entry naming it travels in agents.json, so an imported conversation arrives in exactly the
|
|
72
72
|
* shape the system already has a name for. `attached()` reports its checkout as absent, the board renders
|
|
73
73
|
* it, and the next turn's `ensure()` re-creates it from the recorded composition, which is the same path an
|
|
74
74
|
* archived agent takes when it runs again. The boot sweep's `git worktree prune` clears the stale admin
|
|
@@ -84,7 +84,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
84
84
|
{ path: ".isolation-probe", portability: "derived" },
|
|
85
85
|
/* The finished bundles themselves. `derived` is doing real work here rather than describing leftovers: an
|
|
86
86
|
* export that carried the export directory would pack every previous bundle into the new one, and the next
|
|
87
|
-
* export would pack THAT
|
|
87
|
+
* export would pack THAT, each one a multiple of the last. Living on this volume is the other half of the
|
|
88
88
|
* same guard; under `/work` the file would also be watched, indexed by iq, and snapshotted into history. */
|
|
89
89
|
{ path: "exports/", portability: "derived" },
|
|
90
90
|
|
|
@@ -95,7 +95,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
95
95
|
{
|
|
96
96
|
path: "ssh-hosts/",
|
|
97
97
|
portability: "secret",
|
|
98
|
-
note: "Re-add each ssh host on the Capabilities view
|
|
98
|
+
note: "Re-add each ssh host on the Capabilities view, its key does not travel.",
|
|
99
99
|
},
|
|
100
100
|
// The cli-proxy's config, which holds the routed subscriptions' provider tokens.
|
|
101
101
|
{
|
|
@@ -107,8 +107,8 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
107
107
|
/* ---- identity: what binds this sandbox to its owner, its browsers and its host ---- */
|
|
108
108
|
|
|
109
109
|
/* Signs every browser session cookie. Carrying it would let a bundle's holder mint sessions against the
|
|
110
|
-
* target
|
|
111
|
-
{ path: "session-secret", portability: "identity", note: "Sign in again
|
|
110
|
+
* target, an export becomes a credential, and the target minting its own costs exactly one sign-in. */
|
|
111
|
+
{ path: "session-secret", portability: "identity", note: "Sign in again, the target signs its own sessions." },
|
|
112
112
|
{
|
|
113
113
|
path: "browser-access-disabled",
|
|
114
114
|
portability: "identity",
|
|
@@ -117,7 +117,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
117
117
|
{
|
|
118
118
|
path: "push.json",
|
|
119
119
|
portability: "identity",
|
|
120
|
-
note: "Re-enable notifications in the browsers you use
|
|
120
|
+
note: "Re-enable notifications in the browsers you use, a push subscription is bound to the sandbox that minted it.",
|
|
121
121
|
},
|
|
122
122
|
{ path: "sync-enrollments.json", portability: "identity", note: "Re-pair desktop sync from the Sync tab." },
|
|
123
123
|
{ path: "sync-pair-consumed.json", portability: "identity" },
|
package/src/host-protocol.ts
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
/* The handshake on /system/hosts/connect
|
|
3
|
+
/* The handshake on /system/hosts/connect, the ONE message that is not oRPC.
|
|
4
4
|
*
|
|
5
5
|
* Everything a connected computer is asked lives in `hostContract` (contracts/host.contract.ts), spoken over
|
|
6
6
|
* this socket by oRPC's websocket adapter: the machine hosts the server, the daemon holds the client. But a
|
|
7
7
|
* socket has to prove whose it is before it can be given a typed client, and that proof cannot itself be an
|
|
8
|
-
* oRPC call
|
|
8
|
+
* oRPC call, the daemon has nothing to call yet, and would be attaching a link to a stranger.
|
|
9
9
|
*
|
|
10
10
|
* So the machine's first act is this frame, in plain JSON. The daemon verifies the token, learns which
|
|
11
11
|
* capability the socket belongs to, and only then attaches the link; from that message on, every byte on the
|
|
12
12
|
* wire is oRPC. Anything arriving before the link exists is either this frame or a closed socket. */
|
|
13
13
|
|
|
14
14
|
// The MCP protocol revision the machine's tool server implements. Shared because the daemon answers the
|
|
15
|
-
// handshake ITSELF when the machine is asleep (hosts/host.routes.ts)
|
|
15
|
+
// handshake ITSELF when the machine is asleep (hosts/host.routes.ts), two spellings of this would mean an
|
|
16
16
|
// offline machine negotiating a different protocol than the same machine awake.
|
|
17
17
|
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
18
18
|
|
|
19
19
|
export const HostHelloSchema = z.object({
|
|
20
20
|
type: z.literal("hello"),
|
|
21
|
-
/* The machine's enrollment token
|
|
21
|
+
/* The machine's enrollment token, in the FIRST FRAME, never in the URL. A WebSocket has no headers to put
|
|
22
22
|
* it in, and the obvious `?token=` would write a durable key to somebody's laptop into Cloudflare's edge
|
|
23
23
|
* logs, the connector's logs and every proxy in between (the reasoning that moved the browser's upgrades
|
|
24
|
-
* onto one-shot tickets
|
|
24
|
+
* onto one-shot tickets, auth/ws-tickets.ts). A frame is body, not URL, so it is logged nowhere. Until this
|
|
25
25
|
* arrives the socket is anonymous and short-lived: the daemon closes it in seconds if it never does. */
|
|
26
26
|
token: z.string(),
|
|
27
|
-
// The @intentic/host build the machine is running
|
|
27
|
+
// The @intentic/host build the machine is running, surfaced per machine so an old binary is visible rather
|
|
28
28
|
// than mysteriously missing a tool. What the machine IS (`describe`) is not here: it is pulled over the
|
|
29
29
|
// typed link a moment later, so there is one definition of those facts rather than two.
|
|
30
30
|
version: z.string(),
|
|
31
31
|
});
|
|
32
32
|
export type HostHello = z.infer<typeof HostHelloSchema>;
|
|
33
33
|
|
|
34
|
-
// The URL the machine's agent dials, given the sandbox's public URL. Carries no credential
|
|
34
|
+
// The URL the machine's agent dials, given the sandbox's public URL. Carries no credential, the token rides
|
|
35
35
|
// the hello frame. One place builds it, so the agent and the daemon route can't disagree about where it lives.
|
|
36
36
|
export const hostConnectUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/^http/, "ws").replace(/\/$/, "")}/system/hosts/connect`;
|
package/src/hostnames.test.ts
CHANGED
|
@@ -42,7 +42,7 @@ test("the local sync folder carries the very id the sandbox's URL does", () => {
|
|
|
42
42
|
const url = `https://${sandboxHostname(ID, "intentic.dev")}`;
|
|
43
43
|
expect(sandboxIdFromUrl(url)).toBe(ID);
|
|
44
44
|
expect(syncFolder("Radarsu Local", url)).toBe(`~/intentic/radarsu-local-${ID}`);
|
|
45
|
-
// A bare host (no scheme) and a port are both accepted
|
|
45
|
+
// A bare host (no scheme) and a port are both accepted: the daemon/CLI and the browser pass different shapes.
|
|
46
46
|
expect(sandboxIdFromUrl(`${sandboxHostname(ID, "intentic.dev")}:443`)).toBe(ID);
|
|
47
47
|
// Own-Cloudflare: the owner's chosen subdomain IS the id, so the folder still matches what they browse to.
|
|
48
48
|
expect(syncFolder("shop", "https://dev-box.example.com")).toBe("~/intentic/shop-dev-box");
|
package/src/hostnames.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The single source for the tunnel/preview hostname + ingress scheme, shared by the CLI, platform API, daemon,
|
|
2
2
|
// AND the browser. Pure string builders/parsers with NO node imports (unlike ./tunnel-ids, which needs
|
|
3
|
-
// node:crypto for the digest)
|
|
3
|
+
// node:crypto for the digest), so the web bundle can import them and derive identical names. The caller supplies
|
|
4
4
|
// the 12-hex id (sandboxIdFromToken/hostSshIdFromToken in node; WebCrypto in the browser).
|
|
5
5
|
//
|
|
6
6
|
// All four apps MUST agree on these strings: a divergence resolves to NXDOMAIN that resolvers negative-cache for
|
|
@@ -18,7 +18,7 @@ export const sshHostname = (id: string, zone: string): string => `ssh-${id}.${zo
|
|
|
18
18
|
*
|
|
19
19
|
* A public DNS name for a private address looks odd until you ask what the alternative is. A browser on the
|
|
20
20
|
* same machine as the sandbox can reach its daemon in microseconds instead of crossing to a Cloudflare edge
|
|
21
|
-
* and back
|
|
21
|
+
* and back, but only over HTTPS, because Safari refuses http://127.0.0.1 from an HTTPS page as mixed content
|
|
22
22
|
* (WebKit 171934, open since 2017), and HTTPS needs a name a public CA will certify. An IP literal cannot have
|
|
23
23
|
* one; this can. The daemon holds the key and gets the certificate by proving control of the zone over
|
|
24
24
|
* DNS-01 (there is nothing on the public internet for a CA to connect to).
|
|
@@ -36,36 +36,36 @@ export const hostSshTunnelName = (id: string): string => `host-ssh-${id}`;
|
|
|
36
36
|
// The proxied-CNAME target every tunnel points its DNS record at.
|
|
37
37
|
export const cfargotunnelCname = (tunnelId: string): string => `${tunnelId}.cfargotunnel.com`;
|
|
38
38
|
|
|
39
|
-
// The cloudflared ingress catch-all
|
|
39
|
+
// The cloudflared ingress catch-all, must be the LAST rule.
|
|
40
40
|
export const CATCH_ALL = { service: "http_status:404" } as const;
|
|
41
41
|
|
|
42
|
-
// Preview scheme: `preview-<panel>-<sandboxId>.<zone
|
|
42
|
+
// Preview scheme: `preview-<panel>-<sandboxId>.<zone>`, one DNS label (the free Universal SSL `*.<zone>` cert
|
|
43
43
|
// covers exactly one level), where <panel> is `<repo>` or `<repo>--<app>` and <sandboxId> pins the hostname to
|
|
44
44
|
// this sandbox (the shared intentic zone hosts many sandboxes; without the id two users' panels would collide).
|
|
45
|
-
// Port-forward scheme: `port-<slot>-<sandboxId>.<zone
|
|
45
|
+
// Port-forward scheme: `port-<slot>-<sandboxId>.<zone>`, the same shape with a `port-` prefix, where <slot>
|
|
46
46
|
// is one of the sandbox's forward slots (portSlotsFromToken in ./tunnel-ids), not the port number itself:
|
|
47
47
|
// slots keep the intentic-provided path's minted routes bounded and warm while dev servers churn ephemeral
|
|
48
48
|
// ports. The slot labels are salted with the connect token rather than being the letters a…h, so a forwarded
|
|
49
|
-
// port's hostname is not derivable from the (public) sandbox id alone
|
|
49
|
+
// port's hostname is not derivable from the (public) sandbox id alone, see tunnel-ids for why that matters.
|
|
50
50
|
//
|
|
51
|
-
// Outbox scheme: `public-<slot>-<sandboxId>.<zone
|
|
51
|
+
// Outbox scheme: `public-<slot>-<sandboxId>.<zone>`, the same shape again, serving the workspace's `public/`
|
|
52
52
|
// directory as static files. <slot> is publicSlotFromToken (./tunnel-ids), salted for the same reason the port
|
|
53
53
|
// slots are, and one record per sandbox rather than a pool: there is one outbox, and its link has to stay good
|
|
54
54
|
// for as long as the file does.
|
|
55
55
|
//
|
|
56
56
|
// A *label* is the first-DNS-label prefix before `-<sandboxId>` (`preview-<panel>` / `port-<slot>` /
|
|
57
|
-
// `public-<slot>`)
|
|
57
|
+
// `public-<slot>`), the unit the platform's /sandbox/preview-route mints, so one endpoint serves all three.
|
|
58
58
|
export const previewLabel = (panel: string): string => `preview-${panel}`;
|
|
59
59
|
export const portLabel = (slot: string): string => `port-${slot}`;
|
|
60
60
|
export const publicLabel = (slot: string): string => `public-${slot}`;
|
|
61
61
|
|
|
62
|
-
// The hostname a label resolves to
|
|
62
|
+
// The hostname a label resolves to, what the platform's /sandbox/preview-route mints from the label alone.
|
|
63
63
|
export const labelHostname = (label: string, id: string, zone: string): string => `${label}-${id}.${zone}`;
|
|
64
64
|
export const previewHostname = (panel: string, id: string, zone: string): string => labelHostname(previewLabel(panel), id, zone);
|
|
65
65
|
export const portHostname = (slot: string, id: string, zone: string): string => labelHostname(portLabel(slot), id, zone);
|
|
66
66
|
export const publicHostname = (slot: string, id: string, zone: string): string => labelHostname(publicLabel(slot), id, zone);
|
|
67
67
|
|
|
68
|
-
// A label's public URL
|
|
68
|
+
// A label's public URL, undefined unless the sandbox has both a zone and an id (headless/loopback sandboxes
|
|
69
69
|
// have neither and advertise nothing). One builder, three vocabularies: a panel's preview, a forwarded port's,
|
|
70
70
|
// and the outbox's.
|
|
71
71
|
const labelUrl = (label: string, zone: string | undefined, sandboxId: string | undefined): string | undefined =>
|
|
@@ -79,7 +79,7 @@ export const publicUrl = (slot: string, zone: string | undefined, sandboxId: str
|
|
|
79
79
|
|
|
80
80
|
// The key after `<prefix>` from a request's Host header. The first DNS label must carry the prefix (the
|
|
81
81
|
// own-Cloudflare wildcard also catches stray subdomains → undefined → the caller's 404) and, when the sandbox
|
|
82
|
-
// has an id, the exact `-<sandboxId>` suffix
|
|
82
|
+
// has an id, the exact `-<sandboxId>` suffix, a fixed-length match, so keys containing `-` stay unambiguous.
|
|
83
83
|
// Without an id the bare label is the key (loopback tests and provider-deployed workspaces, which front the
|
|
84
84
|
// proxy themselves).
|
|
85
85
|
const keyFromHost = (prefix: string, hostHeader: string | undefined, sandboxId: string | undefined): string | undefined => {
|
|
@@ -103,7 +103,7 @@ export const publicSlotFromHost = (hostHeader: string | undefined, sandboxId: st
|
|
|
103
103
|
keyFromHost("public-", hostHeader, sandboxId);
|
|
104
104
|
|
|
105
105
|
// The sandbox's identity AS THE USER SEES IT: the leading DNS label of its public URL, minus the `sandbox-`
|
|
106
|
-
// prefix
|
|
106
|
+
// prefix, `https://sandbox-0f310c3c4db4.intentic.dev` → `0f310c3c4db4`, i.e. sandboxIdFromToken's digest read
|
|
107
107
|
// back off the wire by anyone holding only the URL. On the own-Cloudflare path the label is whatever subdomain
|
|
108
108
|
// the owner chose, so that is the id there. undefined until the sandbox has a URL at all.
|
|
109
109
|
export const sandboxIdFromUrl = (url: string | undefined): string | undefined => {
|
|
@@ -124,8 +124,8 @@ export const sandboxIdFromUrl = (url: string | undefined): string | undefined =>
|
|
|
124
124
|
};
|
|
125
125
|
|
|
126
126
|
// The LOCAL folder desktop sync mirrors /work into: `~/intentic/<sandbox name>-<sandboxIdFromUrl>`. Both halves
|
|
127
|
-
// are strings the user already has in front of them
|
|
128
|
-
// bar
|
|
127
|
+
// are strings the user already has in front of them, the name in the sandbox switcher, the id in the address
|
|
128
|
+
// bar, so the folder on disk and the sandbox it mirrors read as ONE identity: `~/intentic/shop-0f310c3c4db4`
|
|
129
129
|
// belongs to `https://sandbox-0f310c3c4db4.intentic.dev` and nothing else. Keyed on the URL rather than the
|
|
130
130
|
// name alone for the same reason the hostname is: a torn-down sandbox recreated under the same name gets a new
|
|
131
131
|
// id, hence its own fresh folder instead of reusing the dead one's (which cleanup never deletes) and colliding
|
|
@@ -142,7 +142,7 @@ export const syncFolder = (name: string, url: string | undefined): string => {
|
|
|
142
142
|
|
|
143
143
|
// The Cloudflare zone from a sandbox public URL (https://sandbox-<id>.<zone> → <zone>): the hostname minus its
|
|
144
144
|
// first DNS label. undefined when the URL is unparsable OR the hostname has fewer than three labels (no zone
|
|
145
|
-
// suffix to strip
|
|
145
|
+
// suffix to strip, e.g. a 2-label host would otherwise yield a bare TLD). Accepts scheme-less input too, so it
|
|
146
146
|
// works whether the caller passes `https://…` (daemon/CLI) or a bare host. This is the single reconciled
|
|
147
147
|
// implementation of what used to be the daemon's `zoneFromPublicUrl` and the web's `zoneFromDaemonUrl`.
|
|
148
148
|
export const zoneFromUrl = (url: string | undefined): string | undefined => {
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { ciContract } from "./contracts/ci.contract.js";
|
|
|
10
10
|
import { claudeContract } from "./contracts/claude.contract.js";
|
|
11
11
|
import { draftsContract } from "./contracts/drafts.contract.js";
|
|
12
12
|
import { endpointsContract } from "./contracts/endpoints.contract.js";
|
|
13
|
+
import { exitContract } from "./contracts/exit.contract.js";
|
|
13
14
|
import { extensionsContract } from "./contracts/extensions.contract.js";
|
|
14
15
|
import { personasContract } from "./contracts/personas.contract.js";
|
|
15
16
|
import { gitContract } from "./contracts/git.contract.js";
|
|
@@ -47,13 +48,14 @@ export { ciContract } from "./contracts/ci.contract.js";
|
|
|
47
48
|
export { claudeContract } from "./contracts/claude.contract.js";
|
|
48
49
|
export { draftsContract } from "./contracts/drafts.contract.js";
|
|
49
50
|
export { endpointsContract, type TrialHealth, TrialStatusSchema, type TrialStatusResponse } from "./contracts/endpoints.contract.js";
|
|
51
|
+
export { exitContract } from "./contracts/exit.contract.js";
|
|
50
52
|
export { extensionsContract } from "./contracts/extensions.contract.js";
|
|
51
53
|
export { personasContract } from "./contracts/personas.contract.js";
|
|
52
54
|
export { gitContract } from "./contracts/git.contract.js";
|
|
53
55
|
export { grokContract } from "./contracts/grok.contract.js";
|
|
54
56
|
export { historyContract } from "./contracts/history.contract.js";
|
|
55
57
|
/* Deliberately NOT part of `sandboxContract` below: that map is the daemon's own HTTP surface, and this one is
|
|
56
|
-
* spoken the other way round
|
|
58
|
+
* spoken the other way round, over a connected computer's WebSocket, with the MACHINE implementing it. */
|
|
57
59
|
export { hostContract } from "./contracts/host.contract.js";
|
|
58
60
|
export { intenticContract } from "./contracts/intentic.contract.js";
|
|
59
61
|
export { inventoryContract } from "./contracts/inventory.contract.js";
|
|
@@ -82,13 +84,13 @@ export * from "./routes.js";
|
|
|
82
84
|
/* THE CONTAINER'S FIXED DIRECTORY LAYOUT, re-exported so extensions can reach it.
|
|
83
85
|
*
|
|
84
86
|
* The names are defined once in @intentic/constants, which sits at the bottom of the dependency graph. An
|
|
85
|
-
* EXTENSION may not import that package
|
|
87
|
+
* EXTENSION may not import that package, the boundary rule (.oxlintrc.json, _extensions/README.md) allows
|
|
86
88
|
* only the SDK halves and this contract, so that an extension cannot couple itself to app or engine internals.
|
|
87
89
|
* That rule is right, and it left extensions with no way to name the workspace root except by spelling it.
|
|
88
90
|
*
|
|
89
91
|
* Re-exporting here is what closes that gap without widening the boundary: the layout is exactly the kind of
|
|
90
|
-
* thing this package already carries
|
|
91
|
-
* state-file table below
|
|
92
|
+
* thing this package already carries, shared vocabulary both sides of the wire must agree on, alongside the
|
|
93
|
+
* state-file table below, and there is still one definition, in one place, that everything resolves to. */
|
|
92
94
|
export { HISTORY_ROOT, HOST_STATE_ROOT, STATE_DIR, WORKSPACE_ROOT } from "@intentic/constants";
|
|
93
95
|
export * from "./workspace-state.js";
|
|
94
96
|
export * from "./runtime-state.js";
|
|
@@ -99,11 +101,13 @@ export * from "./agent-run-model.js";
|
|
|
99
101
|
export * from "./capability-env.js";
|
|
100
102
|
export * from "./capability-secrets.js";
|
|
101
103
|
export * from "./conversation-ids.js";
|
|
104
|
+
export * from "./fast-tier.js";
|
|
102
105
|
export * from "./host-protocol.js";
|
|
103
106
|
export * from "./listener-protocol.js";
|
|
104
107
|
export * from "./hostnames.js";
|
|
105
108
|
export * from "./model-order.js";
|
|
106
109
|
export * from "./path-refs.js";
|
|
110
|
+
export * from "./prompt-complexity.js";
|
|
107
111
|
export * from "./quick-model.js";
|
|
108
112
|
export * from "./output-fields.js";
|
|
109
113
|
export * from "./publish-drafts.js";
|
|
@@ -114,7 +118,7 @@ export * from "./title.js";
|
|
|
114
118
|
export * from "./versions.js";
|
|
115
119
|
export * from "./workflow-faults.js";
|
|
116
120
|
|
|
117
|
-
// The aggregated contract
|
|
121
|
+
// The aggregated contract, implemented on the server by the per-domain route factories and consumed by the
|
|
118
122
|
// browser's typed oRPC client (ContractRouterClient<typeof sandboxContract>). The wire paths it declares are
|
|
119
123
|
// mounted at the sandbox root, so /health and /workspace/raw (plain Hono routes) sit alongside it.
|
|
120
124
|
export const sandboxContract = {
|
|
@@ -153,18 +157,19 @@ export const sandboxContract = {
|
|
|
153
157
|
translator: translatorContract,
|
|
154
158
|
usage: usageContract,
|
|
155
159
|
vpn: vpnContract,
|
|
160
|
+
exit: exitContract,
|
|
156
161
|
workflows: workflowsContract,
|
|
157
162
|
};
|
|
158
163
|
|
|
159
164
|
// Every route in THIS build of the contract, and the names the daemon advertises on its hello frame. Bound here
|
|
160
|
-
// rather than in routes.ts so that module stays a pure function of whatever contract it is handed
|
|
165
|
+
// rather than in routes.ts so that module stays a pure function of whatever contract it is handed, importing
|
|
161
166
|
// `sandboxContract` from there would close a load-time cycle back through this file. See routes.ts for why a
|
|
162
167
|
// daemon advertises its route surface at all.
|
|
163
168
|
export const SANDBOX_ROUTES: readonly ContractRoute[] = contractRoutes(sandboxContract);
|
|
164
169
|
export const SANDBOX_ROUTE_NAMES: readonly string[] = SANDBOX_ROUTES.map((route) => route.name);
|
|
165
170
|
|
|
166
171
|
/* And the SHAPE of each of those routes, advertised beside the names for the failure the names cannot describe:
|
|
167
|
-
* a route both builds have, answering a payload only one of them expects. Computed once at module load
|
|
172
|
+
* a route both builds have, answering a payload only one of them expects. Computed once at module load, it
|
|
168
173
|
* walks every contract schema through `z.toJSONSchema`, which is far too much work to repeat per connection and
|
|
169
174
|
* exactly the kind of thing that never changes for the life of a process. See routes.ts. */
|
|
170
175
|
export const SANDBOX_ROUTE_SHAPES: Readonly<Record<string, string>> = routeShapes(sandboxContract);
|
|
@@ -174,7 +179,7 @@ export const sandboxRouteName = (method: string, pathWithQuery: string): string
|
|
|
174
179
|
routeNameForRequest(SANDBOX_ROUTES, method, pathWithQuery);
|
|
175
180
|
|
|
176
181
|
// The method and concrete path a TYPED call is about to put on the wire, bound to this build's route table.
|
|
177
|
-
// Undefined when the procedure is not one this contract declares, which a typed caller cannot reach
|
|
182
|
+
// Undefined when the procedure is not one this contract declares, which a typed caller cannot reach, the host
|
|
178
183
|
// gate treats it as a refusal rather than assuming it is harmless.
|
|
179
184
|
export const sandboxRequestFor = (procedure: readonly string[], input: unknown): { method: string; path: string } | undefined => {
|
|
180
185
|
const route = routeForProcedure(SANDBOX_ROUTES, procedure);
|
package/src/listener-protocol.ts
CHANGED
|
@@ -3,14 +3,14 @@ import { ActivityStatusSchema } from "./schemas.js";
|
|
|
3
3
|
|
|
4
4
|
/* The wire between the daemon and an extension's realtime-listener GATEWAY process (ext-discord, ext-slack,
|
|
5
5
|
* ext-telegram, ext-whatsapp, ext-imap): the four provider-scoped routes app.ts mounts under
|
|
6
|
-
* /listeners/:provider
|
|
6
|
+
* /listeners/:provider, state, dispatch, failure, status. These shapes used to live daemon-side only, with
|
|
7
7
|
* every gateway hand-writing its own copy of the payloads as untyped literals; a field rename broke five
|
|
8
|
-
* producers silently. They live in the contract now so BOTH ends compile against one declaration
|
|
8
|
+
* producers silently. They live in the contract now so BOTH ends compile against one declaration, the daemon
|
|
9
9
|
* parses with the schemas, the gateways (via @intentic/connector-runtime) type against the inferred types. */
|
|
10
10
|
|
|
11
|
-
// One normalized inbound event
|
|
11
|
+
// One normalized inbound event, serialized as a JSON line in the automation's payload, and the JSON body a
|
|
12
12
|
// realtime source POSTs to /listeners/<provider>/dispatch. A zod schema (not a bare interface) because it's
|
|
13
|
-
// parsed from an extension gateway's request; `provider` and `type` are open strings
|
|
13
|
+
// parsed from an extension gateway's request; `provider` and `type` are open strings, the source is
|
|
14
14
|
// extension-declared (contributes.listener), not a core enum.
|
|
15
15
|
export const ListenerMessageSchema = z.object({
|
|
16
16
|
provider: z.string().min(1),
|
|
@@ -22,7 +22,7 @@ export const ListenerMessageSchema = z.object({
|
|
|
22
22
|
// Discord message: it @mentions one of our bots or replies to a bot's message. Voice events never set it.
|
|
23
23
|
mentioned: z.boolean().optional(),
|
|
24
24
|
// CI pipeline event: the ref it ran on. Top-level rather than inside `extra` for the same reason
|
|
25
|
-
// `mentioned` is
|
|
25
|
+
// `mentioned` is, the dispatcher MATCHES on it, and a narrowing axis the trigger can name has to be
|
|
26
26
|
// a field of the message rather than a key in a provider's opaque bag.
|
|
27
27
|
branch: z.string().optional(),
|
|
28
28
|
// Prior channel messages (chronological) fetched when a bot is tagged, so the agent can reason about why.
|
|
@@ -45,13 +45,13 @@ export const ListenerMessageSchema = z.object({
|
|
|
45
45
|
});
|
|
46
46
|
export type ListenerMessage = z.infer<typeof ListenerMessageSchema>;
|
|
47
47
|
|
|
48
|
-
// One ndjson frame of a /listeners/<provider>/dispatch?stream=1 response
|
|
48
|
+
// One ndjson frame of a /listeners/<provider>/dispatch?stream=1 response, a text delta for one automation's
|
|
49
49
|
// reply, the provider's own failure sentence, or that automation's terminal marker. A type, not a schema: the
|
|
50
50
|
// DAEMON produces these (listener.routes.ts), so nothing parses them from untrusted input.
|
|
51
51
|
export interface ListenerDispatchFrame {
|
|
52
52
|
readonly automationId: string;
|
|
53
53
|
readonly delta?: string;
|
|
54
|
-
// The turn refused or broke, in the provider's own words
|
|
54
|
+
// The turn refused or broke, in the provider's own words, forwarded verbatim because a gateway delivers
|
|
55
55
|
// into the owner's own channel, where the actual sentence is the useful thing.
|
|
56
56
|
readonly failed?: string;
|
|
57
57
|
readonly end?: boolean;
|
|
@@ -61,16 +61,16 @@ export interface ListenerDispatchFrame {
|
|
|
61
61
|
*
|
|
62
62
|
* A CODE IS NOT THE ONLY THING WORTH SAYING, and publishing only codes is what made a phone that had never
|
|
63
63
|
* linked read as connected: the seconds before the first code, a gateway that just restarted, and a number
|
|
64
|
-
* WhatsApp refused all looked identical from the daemon's side
|
|
64
|
+
* WhatsApp refused all looked identical from the daemon's side, an absent code, so the card fell through to
|
|
65
65
|
* "ready" and the owner was sent away from the one screen that could have shown them the step. Each of those is
|
|
66
66
|
* its own state here, and every one of them means NOT PAIRED YET.
|
|
67
67
|
*
|
|
68
68
|
* `since` stamps the CURRENT code. WhatsApp closes an unpaired socket after a minute or so and each reopen mints
|
|
69
|
-
* a fresh code, so a code is a thing with an age
|
|
69
|
+
* a fresh code, so a code is a thing with an age, the card says how old the one on screen is rather than
|
|
70
70
|
* leaving the owner to type a dead one twice. */
|
|
71
71
|
export const ListenerPairingSchema = z.object({
|
|
72
72
|
// waiting: a socket is up and the code hasn't arrived (or the last one died with its socket).
|
|
73
|
-
// code: `code` is live
|
|
73
|
+
// code: `code` is live, type it on the phone. failed: `detail` says what WhatsApp refused.
|
|
74
74
|
state: z.enum(["waiting", "code", "failed"]),
|
|
75
75
|
code: z.string().optional(),
|
|
76
76
|
detail: z.string().optional(),
|
|
@@ -79,11 +79,11 @@ export const ListenerPairingSchema = z.object({
|
|
|
79
79
|
export type ListenerPairing = z.infer<typeof ListenerPairingSchema>;
|
|
80
80
|
|
|
81
81
|
// Push-based listener status: a gateway process POSTs its live connection/voice snapshot to
|
|
82
|
-
// /listeners/<provider>/status, and the activity route reads it from there
|
|
82
|
+
// /listeners/<provider>/status, and the activity route reads it from there, the daemon holds no provider
|
|
83
83
|
// connection of its own to probe. The body IS the ActivityStatus the /activity/status probe used to build from
|
|
84
84
|
// in-process discord singletons, plus the per-gateway extras that ride the same channel: whether whisper is
|
|
85
85
|
// present (discord's voice-pending signal) and each unpaired capability's ceremony by id (whatsapp's
|
|
86
|
-
// link-a-device flow
|
|
86
|
+
// link-a-device flow, the capability card renders it as the step the owner is standing in front of).
|
|
87
87
|
export const ListenerStatusSchema = ActivityStatusSchema.extend({
|
|
88
88
|
whisperReady: z.boolean().optional(),
|
|
89
89
|
pairing: z.record(z.string(), ListenerPairingSchema).optional(),
|