@intentic/sandbox-contract 1.175.0 → 1.176.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/agent-catalog.d.ts +4 -0
  2. package/dist/agent-catalog.d.ts.map +1 -1
  3. package/dist/agent-catalog.js +7 -0
  4. package/dist/agent-catalog.js.map +1 -1
  5. package/dist/contracts/capabilities.contract.d.ts +42 -0
  6. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  7. package/dist/contracts/endpoints.contract.d.ts +18 -0
  8. package/dist/contracts/endpoints.contract.d.ts.map +1 -0
  9. package/dist/contracts/endpoints.contract.js +6 -0
  10. package/dist/contracts/endpoints.contract.js.map +1 -0
  11. package/dist/contracts/extensions.contract.d.ts +4 -0
  12. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  13. package/dist/contracts/git.contract.d.ts +5 -1
  14. package/dist/contracts/git.contract.d.ts.map +1 -1
  15. package/dist/contracts/host.contract.d.ts +36 -0
  16. package/dist/contracts/host.contract.d.ts.map +1 -0
  17. package/dist/contracts/host.contract.js +10 -0
  18. package/dist/contracts/host.contract.js.map +1 -0
  19. package/dist/contracts/settings.contract.d.ts +6 -4
  20. package/dist/contracts/settings.contract.d.ts.map +1 -1
  21. package/dist/contracts/system.contract.d.ts +14 -14
  22. package/dist/contracts/workspace.contract.d.ts +9 -0
  23. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  24. package/dist/contracts/workspace.contract.js +2 -1
  25. package/dist/contracts/workspace.contract.js.map +1 -1
  26. package/dist/host-protocol.d.ts +10 -0
  27. package/dist/host-protocol.d.ts.map +1 -0
  28. package/dist/host-protocol.js +9 -0
  29. package/dist/host-protocol.js.map +1 -0
  30. package/dist/index.d.ts +86 -6
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +5 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/quick-model.d.ts +3 -3
  35. package/dist/quick-model.d.ts.map +1 -1
  36. package/dist/quick-model.js.map +1 -1
  37. package/dist/schemas.d.ts +209 -7
  38. package/dist/schemas.d.ts.map +1 -1
  39. package/dist/schemas.js +48 -3
  40. package/dist/schemas.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/agent-catalog.test.ts +35 -0
  43. package/src/agent-catalog.ts +31 -3
  44. package/src/contracts/endpoints.contract.ts +10 -0
  45. package/src/contracts/host.contract.ts +33 -0
  46. package/src/contracts/workspace.contract.ts +5 -0
  47. package/src/host-protocol.ts +36 -0
  48. package/src/index.ts +7 -1
  49. package/src/quick-model.test.ts +27 -0
  50. package/src/quick-model.ts +22 -12
  51. package/src/schemas.ts +211 -23
  52. package/dist/effects.d.ts +0 -42
  53. package/dist/effects.d.ts.map +0 -1
  54. package/dist/effects.js +0 -79
  55. package/dist/effects.js.map +0 -1
  56. package/src/effects.test.ts +0 -132
  57. package/src/effects.ts +0 -138
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+
3
+ /* The handshake on /system/hosts/connect — the ONE message that is not oRPC.
4
+ *
5
+ * Everything a connected computer is asked lives in `hostContract` (contracts/host.contract.ts), spoken over
6
+ * this socket by oRPC's websocket adapter: the machine hosts the server, the daemon holds the client. But a
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 — the daemon has nothing to call yet, and would be attaching a link to a stranger.
9
+ *
10
+ * So the machine's first act is this frame, in plain JSON. The daemon verifies the token, learns which
11
+ * capability the socket belongs to, and only then attaches the link; from that message on, every byte on the
12
+ * wire is oRPC. Anything arriving before the link exists is either this frame or a closed socket. */
13
+
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) — two spellings of this would mean an
16
+ // offline machine negotiating a different protocol than the same machine awake.
17
+ export const MCP_PROTOCOL_VERSION = "2025-06-18";
18
+
19
+ export const HostHelloSchema = z.object({
20
+ type: z.literal("hello"),
21
+ /* The machine's enrollment token — in the FIRST FRAME, never in the URL. A WebSocket has no headers to put
22
+ * it in, and the obvious `?token=` would write a durable key to somebody's laptop into Cloudflare's edge
23
+ * logs, the connector's logs and every proxy in between (the reasoning that moved the browser's upgrades
24
+ * onto one-shot tickets — auth/ws-tickets.ts). A frame is body, not URL, so it is logged nowhere. Until this
25
+ * arrives the socket is anonymous and short-lived: the daemon closes it in seconds if it never does. */
26
+ token: z.string(),
27
+ // The @intentic/host build the machine is running — surfaced per machine so an old binary is visible rather
28
+ // than mysteriously missing a tool. What the machine IS (`describe`) is not here: it is pulled over the
29
+ // typed link a moment later, so there is one definition of those facts rather than two.
30
+ version: z.string(),
31
+ });
32
+ export type HostHello = z.infer<typeof HostHelloSchema>;
33
+
34
+ // The URL the machine's agent dials, given the sandbox's public URL. Carries no credential — the token rides
35
+ // the hello frame. One place builds it, so the agent and the daemon route can't disagree about where it lives.
36
+ export const hostConnectUrl = (sandboxUrl: string): string => `${sandboxUrl.replace(/^http/, "ws").replace(/\/$/, "")}/system/hosts/connect`;
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 { codexContract } from "./contracts/codex.contract.js";
12
12
  import { draftsContract } from "./contracts/drafts.contract.js";
13
+ import { endpointsContract } from "./contracts/endpoints.contract.js";
13
14
  import { extensionsContract } from "./contracts/extensions.contract.js";
14
15
  import { geminiContract } from "./contracts/gemini.contract.js";
15
16
  import { gitContract } from "./contracts/git.contract.js";
@@ -44,11 +45,15 @@ export { ciContract } from "./contracts/ci.contract.js";
44
45
  export { claudeContract } from "./contracts/claude.contract.js";
45
46
  export { codexContract } from "./contracts/codex.contract.js";
46
47
  export { draftsContract } from "./contracts/drafts.contract.js";
48
+ export { endpointsContract } from "./contracts/endpoints.contract.js";
47
49
  export { extensionsContract } from "./contracts/extensions.contract.js";
48
50
  export { geminiContract } from "./contracts/gemini.contract.js";
49
51
  export { gitContract } from "./contracts/git.contract.js";
50
52
  export { grokContract } from "./contracts/grok.contract.js";
51
53
  export { historyContract } from "./contracts/history.contract.js";
54
+ /* Deliberately NOT part of `sandboxContract` below: that map is the daemon's own HTTP surface, and this one is
55
+ * spoken the other way round — over a connected computer's WebSocket, with the MACHINE implementing it. */
56
+ export { hostContract } from "./contracts/host.contract.js";
52
57
  export { intenticContract } from "./contracts/intentic.contract.js";
53
58
  export { inventoryContract } from "./contracts/inventory.contract.js";
54
59
  export { kimiContract } from "./contracts/kimi.contract.js";
@@ -67,12 +72,12 @@ export { translatorContract } from "./contracts/translator.contract.js";
67
72
  export { usageContract } from "./contracts/usage.contract.js";
68
73
  export { vpnContract } from "./contracts/vpn.contract.js";
69
74
  export { workspaceContract } from "./contracts/workspace.contract.js";
70
- export * from "./effects.js";
71
75
  export * from "./events.js";
72
76
  export * from "./sse.js";
73
77
  export * from "./routes.js";
74
78
  export * from "./workspace-state.js";
75
79
  export * from "./agent-catalog.js";
80
+ export * from "./host-protocol.js";
76
81
  export * from "./hostnames.js";
77
82
  export * from "./model-order.js";
78
83
  export * from "./path-refs.js";
@@ -95,6 +100,7 @@ export const sandboxContract = {
95
100
  claude: claudeContract,
96
101
  codex: codexContract,
97
102
  drafts: draftsContract,
103
+ endpoints: endpointsContract,
98
104
  extensions: extensionsContract,
99
105
  sessions: sessionsContract,
100
106
  settings: settingsContract,
@@ -86,3 +86,30 @@ test("skips a connected provider whose catalog has not loaded yet", () => {
86
86
  expect(resolveQuickModel([unloaded, CLAUDE], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
87
87
  expect(resolveQuickModel([unloaded], ``)).toBeUndefined();
88
88
  });
89
+
90
+ /* A MODEL ENDPOINT the user configured is a provider like any other here, and the reason it has to be is the
91
+ * settings row: its options are built from the same picker catalog, so a pin naming one that this resolver
92
+ * dropped would print one model's name under the sparkle and spend a different account entirely. */
93
+ const OLLAMA: QuickModelSource = { provider: `endpoint/ollama`, ready: true, models: [`qwen3-coder`, `gemma3-27b`] };
94
+
95
+ test("honours a pin on a configured endpoint — the whole id, not the half before its slash", () => {
96
+ expect(resolveQuickModel([CLAUDE, OLLAMA], `endpoint/ollama:qwen3-coder`)).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
97
+ // And it round-trips through the key shape the picker mints, which is where the slash-not-colon rule earns
98
+ // itself: parsePinned splits on the FIRST colon, so an `endpoint:ollama` id would have parsed the provider
99
+ // as "endpoint" and the model as "ollama:qwen3-coder" — a pin that silently resolves to nothing.
100
+ expect(quickModelKey({ provider: `endpoint/ollama`, model: `qwen3-coder` })).toBe(`endpoint/ollama:qwen3-coder`);
101
+ });
102
+
103
+ test("leaves Auto to the providers whose price is known, rather than reaching for someone's own server", () => {
104
+ // Claude publishes a Haiku-class row; the endpoint's ids carry no tier word at all, so they are UNRANKED and
105
+ // lose on tier. What a turn on a user's own model API costs is not a fact this repo holds, and Auto should
106
+ // not be asserting one.
107
+ expect(resolveQuickModel([CLAUDE, OLLAMA], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
108
+ });
109
+
110
+ test("still answers from an endpoint when it is the only thing configured", () => {
111
+ // No tier word in either id, so the shared id-derived ordering decides between them exactly as it does for
112
+ // Kimi above — the point here is that a sandbox whose only model API is its owner's still gets an answer
113
+ // rather than the disabled "nothing connected" button.
114
+ expect(resolveQuickModel([offline(CLAUDE), OLLAMA], ``)).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
115
+ });
@@ -1,6 +1,6 @@
1
1
  import { ACCESS_COST, accessFor, PROVIDERS } from "./agent-catalog.js";
2
2
  import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
3
- import type { NativeProvider } from "./schemas.js";
3
+ import type { AgentProvider } from "./schemas.js";
4
4
 
5
5
  /* THE QUICK MODEL — the cheap, fast model a one-click helper spends instead of the frontier model the chat runs
6
6
  * on. Today that is the commit box's autofill; anything else of that shape (a branch name, a PR description)
@@ -17,11 +17,19 @@ import type { NativeProvider } from "./schemas.js";
17
17
  * this repo's model handling: model-order.ts derives tier and recency from the id and curates nothing, and the
18
18
  * web's defaultModelFor reads the live catalog rather than naming an id that a release will falsify. */
19
19
 
20
- // One provider's standing in the decision: whether a turn on it can be sent at all, and what its catalog holds.
21
- // ACP agents are deliberately not expressible here — an ACP row's model id is empty because the agent owns its
22
- // own model, so there is no cheap rung to point it at.
20
+ /* One provider's standing in the decision: whether a turn on it can be sent at all, and what its catalog holds.
21
+ *
22
+ * ACP agents are deliberately not expressible here — an ACP row's model id is empty because the agent owns its
23
+ * own model, so there is no cheap rung to point it at. `endpoint/<id>` providers ARE, and have to be: their
24
+ * models appear in the same picker the settings row builds its options from, so a pin naming one has to hold
25
+ * rather than fall silently back to Auto and spend an account the user was deliberately steering away from. */
23
26
  export interface QuickModelSource {
24
- readonly provider: NativeProvider;
27
+ // AgentProvider, not NativeProvider: an endpoint's id is user-created and cannot be in a fixed union. Auto's
28
+ // ranking degrades gracefully for one — costOf falls to the metered rung and an id with no tier word is
29
+ // UNRANKED, which is genuine last place — so an endpoint effectively only wins Auto when nothing else is
30
+ // connected, while a PIN on one holds. Both are the right answers: what a turn on someone's own model server
31
+ // costs is not a fact this repo can know, so it is not one Auto should be asserting.
32
+ readonly provider: AgentProvider;
25
33
  // The same connection predicate every other surface gates on (access.ts web-side, the daemon's own account
26
34
  // stores daemon-side). A catalog is never empty by construction, so "has rows" says nothing about "can send".
27
35
  readonly ready: boolean;
@@ -29,7 +37,7 @@ export interface QuickModelSource {
29
37
  }
30
38
 
31
39
  export interface QuickModelChoice {
32
- readonly provider: NativeProvider;
40
+ readonly provider: AgentProvider;
33
41
  readonly model: string;
34
42
  }
35
43
 
@@ -45,7 +53,7 @@ export const parsePinned = (pinned: string): QuickModelChoice | undefined => {
45
53
  if (separator <= 0 || separator === pinned.length - 1) {
46
54
  return undefined;
47
55
  }
48
- return { provider: pinned.slice(0, separator) as NativeProvider, model: pinned.slice(separator + 1) };
56
+ return { provider: pinned.slice(0, separator), model: pinned.slice(separator + 1) };
49
57
  };
50
58
 
51
59
  // The cheapest row a provider publishes — its whole catalog read from the cheap end. Undefined for a catalog
@@ -59,12 +67,14 @@ const cheapestOf = (source: QuickModelSource): string | undefined => source.mode
59
67
  const tierOf = (model: string): number => tierRankOf(familyOf(model));
60
68
 
61
69
  // PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read — the property
62
- // compareUnrankedModelIds exists to guarantee, and the one a default actually needs.
63
- const providerOrder = (provider: NativeProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
70
+ // compareUnrankedModelIds exists to guarantee, and the one a default actually needs. An endpoint is in no fixed
71
+ // list, so it reads -1 and leads the tiebreak; unreachable in practice, since it can never tie on cost.
72
+ const providerOrder = (provider: AgentProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
64
73
 
65
- // How much a call on this provider costs at the margin. Every native provider declares an access kind, so the
66
- // fallback is unreachableit exists because AgentProvider is a bare string on the wire.
67
- const costOf = (provider: NativeProvider): number => {
74
+ // How much a call on this provider costs at the margin. Every native provider declares an access kind; an
75
+ // endpoint declares none, and takes the metered rung the conservative reading of a model API whose bill this
76
+ // repo cannot see, which keeps Auto from reaching for someone's paid gateway on its own initiative.
77
+ const costOf = (provider: AgentProvider): number => {
68
78
  const access = accessFor(provider);
69
79
  return access === undefined ? ACCESS_COST.key : ACCESS_COST[access.kind];
70
80
  };
package/src/schemas.ts CHANGED
@@ -24,8 +24,10 @@ export const SessionTranscriptMessageSchema = z.object({ role: z.enum(["user", "
24
24
  export type SessionTranscriptMessage = z.infer<typeof SessionTranscriptMessageSchema>;
25
25
 
26
26
  // The agent runtimes the daemon can serve — the vocabulary every surface that picks an agent shares (chat
27
- // turns, automations). The NATIVE providers have dedicated adapters (and their ids are reserved); any
28
- // other value is the id of an installed `agent`-kind capability served over ACP (Agent Client Protocol).
27
+ // turns, automations). The NATIVE providers have dedicated adapters (and their ids are reserved); an
28
+ // `endpoint/<id>` value names an installed `endpoint`-kind capability (a model API the user pointed us at,
29
+ // see EndpointConfigSchema); any other value is the id of an installed `agent`-kind capability served over
30
+ // ACP (Agent Client Protocol).
29
31
  // Kept as a bare string on the wire (not an enum) so an unknown id is a clean error frame from the agent
30
32
  // route — the same bet RepoParamSchema makes — and adding an ACP agent needs no contract change.
31
33
  export const NATIVE_PROVIDERS = ["claude", "codex", "grok", "kimi", "gemini"] as const;
@@ -925,24 +927,45 @@ export const SavingsArmSchema = z.object({ turns: z.number(), mean: z.number() }
925
927
  * appends, so it belongs to neither arm.
926
928
  *
927
929
  * `metric` says what `mean` counts and what `deltaPct` is a delta in. The terse steer is judged on the model's
928
- * OWN output tokens, which is the thing it steers. Pre-injection is judged on COST, because it spends input
929
- * tokens deliberately to buy back search turns scored on output tokens it would look like a pure expense,
930
- * and scored on input tokens like a pure loss; the trade only nets out in money. */
930
+ * own PROSE, which is the thing it steers and the only part of its output that responds to being asked to be
931
+ * brief (see UsageTurn.proseChars for why the turn's total output tokens cannot answer this). Pre-injection is
932
+ * judged on COST, because it spends input tokens deliberately to buy back search turns scored on output
933
+ * tokens it would look like a pure expense, and scored on input tokens like a pure loss; the trade only nets
934
+ * out in money. */
931
935
  export const TurnExperimentSchema = z.object({
932
- metric: z.enum(["outputTokens", "costUsd"]),
936
+ metric: z.enum(["proseChars", "costUsd"]),
933
937
  on: SavingsArmSchema,
934
938
  off: SavingsArmSchema,
939
+ /* How much of the treatment arm the treatment actually REACHED, when that is knowable and less than all of
940
+ * it — pre-injection's arm is the coin flip (intention-to-treat, deliberately), and a turn can be assigned
941
+ * the retrieval and still have nothing to prepend. Measured at four turns in five, which is the difference
942
+ * between a mechanism worth little and one worth five times what the delta says.
943
+ *
944
+ * Absent ⇒ delivery is not a separate question for this experiment (the terse steer always lands) or no
945
+ * turn in the window recorded it. The screen shows the delta as diluted rather than silently scaling it:
946
+ * the correction is a division by a rate this small only when the rate is itself well measured. */
947
+ deliveredPct: z.number().optional(),
935
948
  // Turns per arm before a delta is reported at all. Carried on the wire so the screen's "measuring…" state
936
949
  // counts toward the daemon's real threshold instead of a number the browser guessed.
937
950
  minTurns: z.number(),
938
- /* The three below are present TOGETHER, and only once both arms clear `minTurns` a schema that can't
939
- * express a half-measured experiment is how a 34%-that-becomes-8%-tomorrow never reaches the screen.
940
- * deltaPct — change in the metric's mean per turn under the mechanism; negative is a saving.
941
- * marginPct ± percentage points, 95% (Welch, unequal variances and unequal arms).
942
- * saved — what the delta is worth over the turns that actually ran with it, in this window, in the
943
- * metric's own unit (tokens, or dollars). */
944
- deltaPct: z.number().optional(),
951
+ /* THE RESOLUTION, present as soon as both arms clear `minTurns`: ± percentage points at 95% (Welch,
952
+ * unequal variances and unequal arms). Present even when the delta below is withheld, because "whatever
953
+ * this mechanism does, it is smaller than ±35 points" is a true and useful thing to be told — it is the
954
+ * reading that says to keep collecting rather than to act. */
945
955
  marginPct: z.number().optional(),
956
+ /* THE CLAIM, present only once there is one. Both together, and only when the margin does NOT span zero.
957
+ *
958
+ * A schema that can't express a half-measured experiment is how a 34%-that-becomes-8%-tomorrow never
959
+ * reaches the screen — and clearing `minTurns` turned out not to be enough to buy that. The terse steer
960
+ * crossed its thirtieth control turn and immediately reported +31.2% ± 35.1pp: a confidence interval
961
+ * running from −3.4% to +66.7%, which is to say no effect was measured at all, rendered as an alarming
962
+ * number pointing the wrong way. Thirty turns is where the normal approximation starts to hold, not where
963
+ * this much per-turn spread resolves an effect; requiring the interval to exclude zero is the same
964
+ * withhold-until-it-means-something rule applied to the thing that actually decides whether it does.
965
+ * deltaPct — change in the metric's mean per turn under the mechanism; negative is a saving.
966
+ * saved — what the delta is worth over the turns that actually ran with it, in this window, in the
967
+ * metric's own unit (characters, or dollars). */
968
+ deltaPct: z.number().optional(),
946
969
  saved: z.number().optional(),
947
970
  });
948
971
  export type TurnExperiment = z.infer<typeof TurnExperimentSchema>;
@@ -963,17 +986,21 @@ export const IntenticRunSchema = z.object({ args: z.array(z.string()) });
963
986
 
964
987
  // ---- git ----
965
988
 
966
- // What a commit records — two shapes, each a real git spelling:
967
- // all: true ⇒ stage every change in the repo, then commit (`commit -a`; VSCode's "stage all and commit")
989
+ // What a commit records — three shapes, each a real git spelling. The last two are for the case where nothing
990
+ // is staged yet and the caller has said what to stage; they are alternatives, and a caller sends at most one:
968
991
  // absent ⇒ commit whatever is staged (plain `git commit`)
992
+ // all: true ⇒ stage every change in the repo, then commit (`commit -a`; VSCode's "stage all and commit")
993
+ // paths ⇒ `git add` those repo-relative paths, then commit the index
969
994
  //
970
- // There is deliberately no `paths`. The index IS git's mechanism for choosing what a commit contains, so a
971
- // second path-selection channel alongside it can only disagree with it: a `commit --only` over a partially
972
- // staged file records the WORKTREE content while the row the user picked showed the INDEX content. Staging is
973
- // the selection; this endpoint only ever records it.
995
+ // `paths` is emphatically NOT `commit --only`. The index IS git's mechanism for choosing what a commit
996
+ // contains, so a second path-selection channel alongside it could only disagree with it: a partial commit over
997
+ // a half-staged file records the WORKTREE content while the row the user picked showed the INDEX content. This
998
+ // stages and then records the whole index, which is why it is safe — and why it also survives a merge, where
999
+ // git refuses a partial commit outright (and refuses it only AFTER moving the index).
974
1000
  export const CommitSchema = RepoParamSchema.extend({
975
1001
  message: z.string().min(1),
976
1002
  all: z.boolean().optional(),
1003
+ paths: z.array(z.string().min(1)).max(500).optional(),
977
1004
  });
978
1005
  export const DiscardSchema = RepoParamSchema.extend({
979
1006
  // Repo-relative paths to discard; absent ⇒ discard every uncommitted change in the repo.
@@ -1002,13 +1029,21 @@ export const GitFilesSchema = z.object({ files: z.array(z.string()) });
1002
1029
  export const GitFileSchema = z.object({ path: z.string(), content: z.string() });
1003
1030
  export const CommitResultSchema = z.object({ committed: z.boolean() });
1004
1031
 
1032
+ // One repo's slice of a workspace-wide git action: the whole repo, or only the repo-relative paths named. The
1033
+ // same pair the per-repo routes take as {repo} + `paths`, in the one shape a caller that spans repos can send.
1034
+ export const RepoPathsSchema = z.object({ repo: z.string().min(1), paths: z.array(z.string().min(1)).max(500).optional() });
1035
+ export type RepoPaths = z.infer<typeof RepoPathsSchema>;
1036
+
1005
1037
  /* AI-drafted commit message. Workspace-wide, not per repo, because the commit box's target IS a set of repos
1006
1038
  * sharing one message — so the draft has to see every one of their diffs to describe what the commit actually
1007
- * records. `repos` and `all` mirror the panel's own commit target exactly: `all` reads the WORKTREE (what
1008
- * "Commit all" would sweep), absent reads the INDEX (what a bare commit records). Getting that wrong would
1009
- * describe changes the commit isn't going to contain. */
1039
+ * records. The input mirrors CommitSchema field for field, which is the whole point: whatever the commit is
1040
+ * about to do is what gets described, and the two cannot drift.
1041
+ * repos[].paths the subset that commit will stage — read the WORKTREE, narrowed to those paths
1042
+ * all: true ⇒ the whole worktree, untracked included (what "Commit all" sweeps)
1043
+ * neither ⇒ the INDEX (what a bare commit records)
1044
+ * Getting that wrong would describe changes the commit isn't going to contain. */
1010
1045
  export const CommitMessageDraftSchema = z.object({
1011
- repos: z.array(z.string().min(1)).min(1).max(50),
1046
+ repos: z.array(RepoPathsSchema).min(1).max(50),
1012
1047
  all: z.boolean().optional(),
1013
1048
  });
1014
1049
  // The draft plus WHICH model wrote it, so the surface can name it rather than claiming an anonymous "AI" —
@@ -1604,6 +1639,16 @@ export const WorkspaceDepEdgeSchema = z.object({ from: z.string(), to: z.string(
1604
1639
  export type WorkspaceDepEdge = z.infer<typeof WorkspaceDepEdgeSchema>;
1605
1640
  export const WorkspaceGraphSchema = z.object({ packages: z.array(WorkspacePackageSchema), edges: z.array(WorkspaceDepEdgeSchema) });
1606
1641
  export type WorkspaceGraph = z.infer<typeof WorkspaceGraphSchema>;
1642
+ // One module a changed file can be grouped under in the review panels: a repo-relative dir ("_apps/web", or ""
1643
+ // for a repo that is itself one package) and the name its package.json declares. Distinct from
1644
+ // WorkspacePackage, which is the DEPENDENCY graph's node — that one is pnpm's view of the workspace and carries
1645
+ // the grouping axis its diagram colours by; this one is a filesystem fact about where a path lives.
1646
+ export const WorkspaceModuleSchema = z.object({ dir: z.string(), name: z.string() });
1647
+ export type WorkspaceModule = z.infer<typeof WorkspaceModuleSchema>;
1648
+ export const RepoModulesSchema = z.object({ repo: z.string(), modules: z.array(WorkspaceModuleSchema) });
1649
+ export type RepoModules = z.infer<typeof RepoModulesSchema>;
1650
+ export const WorkspaceModulesSchema = z.object({ repos: z.array(RepoModulesSchema) });
1651
+ export type WorkspaceModules = z.infer<typeof WorkspaceModulesSchema>;
1607
1652
  // Path params for the per-repo apps routes: the monorepo name (validated in the handler like PanelRepoParam)
1608
1653
  // and, for per-app preview control (start/stop), the app key (api/web/landing).
1609
1654
  export const RepoAppsParamSchema = z.object({ repo: z.string() });
@@ -1700,7 +1745,9 @@ export const CapabilityKindSchema = z.enum([
1700
1745
  "vpn",
1701
1746
  "docker",
1702
1747
  "browser",
1748
+ "host",
1703
1749
  "agent",
1750
+ "endpoint",
1704
1751
  ]);
1705
1752
  export type CapabilityKind = z.infer<typeof CapabilityKindSchema>;
1706
1753
  export const CapabilityStateSchema = z.enum(["active", "pending", "error", "inactive"]);
@@ -1868,6 +1915,43 @@ export const VpnConfigSchema = z.discriminatedUnion("provider", [WireguardVpnCon
1868
1915
  // Dockerfile fragment, applied on an owner rebuild. One capability = one platform (the id doubles as the profile).
1869
1916
  export const BrowserPlatformSchema = z.enum(["reddit", "x", "youtube"]);
1870
1917
  export const BrowserConfigSchema = z.object({ platform: BrowserPlatformSchema });
1918
+ /* A connected COMPUTER of the user's own — the inverse of `ssh`, which reaches a server the sandbox can dial.
1919
+ * A machine behind NAT can't be dialled, so it dials US: the @intentic/host agent (installed by a one-liner,
1920
+ * enrolled with a single-use pairing token) holds one outbound WebSocket to this daemon and serves an MCP tool
1921
+ * surface — shell, files, screenshots — from the far end. The daemon tunnels the agent's JSON-RPC over it and
1922
+ * never implements a tool itself, so the machine's capabilities evolve with ITS binary, not with a daemon release.
1923
+ *
1924
+ * One capability = one machine. The id is the machine's name and namespaces its tools (mcp__laptop__run_command),
1925
+ * so several connected machines never collide — the `ssh` precedent. `platform` splits the SKILL pack: a Windows
1926
+ * machine is taught PowerShell and a Linux one systemd/D-Bus, and neither carries the other's noise.
1927
+ *
1928
+ * SCOPES ARE THE GRANT, and they are enforced ON THE MACHINE, never here: the daemon pushes them down on every
1929
+ * connect, and the agent refuses out-of-scope calls itself. So a sandbox that is compromised — or an agent talked
1930
+ * into it by something it read on the internet — still cannot exceed what the owner ticked. `roots` bounds file
1931
+ * reads AND writes to a set of directories (empty ⇒ the user's home). */
1932
+ export const HostPlatformSchema = z.enum(["windows", "linux"]);
1933
+ export type HostPlatform = z.infer<typeof HostPlatformSchema>;
1934
+ // on/off rather than a boolean: capability configs arrive from the add form as strings (the vpn autoConnect
1935
+ // precedent), and a select is what the form renders for an enum.
1936
+ const hostScope = z.enum(["on", "off"]);
1937
+ export const HostScopesSchema = z.object({
1938
+ // Run commands in a real shell (PowerShell on Windows, the login shell on Linux). Off ⇒ files/screen only.
1939
+ shell: hostScope.default("on"),
1940
+ // Create, modify and trash files under `roots`. Reads are always allowed within them; this is the write half.
1941
+ write: hostScope.default("off"),
1942
+ // Capture the screen. Off ⇒ screenshot refuses, and the agent is told so rather than getting a black frame.
1943
+ screen: hostScope.default("on"),
1944
+ /* Move the pointer, click, type and scroll — GUI work, for the things with no command-line way in. Its own
1945
+ * switch rather than part of `screen` because looking and touching are not the same permission: a screenshot
1946
+ * is bounded by what is on the display, while one click can confirm a dialog nobody read. Default off, like
1947
+ * `write`, and for the same reason — a user who has not thought about it should not discover the agent has
1948
+ * been driving their desktop. */
1949
+ control: hostScope.default("off"),
1950
+ // One directory per line. Empty ⇒ the machine's home directory, which is what the agent reports at connect.
1951
+ roots: z.string().optional(),
1952
+ });
1953
+ export type HostScopes = z.infer<typeof HostScopesSchema>;
1954
+ export const HostConfigSchema = HostScopesSchema.extend({ platform: HostPlatformSchema });
1871
1955
  // An ACP (Agent Client Protocol) agent served as a chat provider: the daemon spawns `command` as a long-lived
1872
1956
  // subprocess speaking JSON-RPC over stdio, and the capability id becomes the provider id in the chat picker
1873
1957
  // (see AgentProviderSchema). `command` is split on whitespace — no shell quoting. `env` is a pasted KEY=VALUE
@@ -1881,6 +1965,36 @@ export const AcpAgentConfigSchema = z.object({
1881
1965
  env: z.string().optional(),
1882
1966
  loginCommand: z.string().min(1).optional(),
1883
1967
  });
1968
+
1969
+ /* A MODEL API THE USER POINTED US AT — one shape for every server that serves models over HTTP, whether it runs
1970
+ * beside this container or in another datacentre. There is deliberately NO local/remote axis: an Ollama on the
1971
+ * docker host, a vLLM on the GPU box down the hall, a LiteLLM gateway and OpenRouter differ only in the URL, and
1972
+ * inventing a distinction would mean two code paths, two cards and two sets of bugs for one concept.
1973
+ *
1974
+ * `protocol` is the only real fork, and it is about the WIRE, not about where the server lives:
1975
+ * openai — the endpoint speaks OpenAI /v1/chat/completions (Ollama, vLLM, llama.cpp, LM Studio, TGI,
1976
+ * OpenRouter, most gateways). The Claude Code harness speaks only the Anthropic Messages API, so
1977
+ * these are re-served through the bundled translator, which is already in the image for exactly
1978
+ * this job (agent/translator.ts). The user's key stays in the translator's config on /history and
1979
+ * never reaches the harness — it gets the loopback bearer instead.
1980
+ * anthropic — the endpoint already speaks the Anthropic Messages API (LiteLLM's /v1/messages, a Bedrock or
1981
+ * Vertex router, a corporate Anthropic gateway). Nothing to translate: the harness is pointed
1982
+ * straight at it with the user's own key.
1983
+ *
1984
+ * `headers` is a pasted `Name: value` block, one per line — the extra headers gateways ask for (a tenant id, a
1985
+ * routing hint). The key is the secret field; the header block is not, because it is where non-credential
1986
+ * routing metadata lives and hiding it would make a misrouted endpoint undiagnosable. */
1987
+ export const EndpointProtocolSchema = z.enum(["openai", "anthropic"]);
1988
+ export type EndpointProtocol = z.infer<typeof EndpointProtocolSchema>;
1989
+ export const EndpointConfigSchema = z.object({
1990
+ // The API root, INCLUDING the version segment the server publishes (…:11434/v1). Taken verbatim rather than
1991
+ // normalised: "which suffix does this server want" is the one thing that actually varies between them, and
1992
+ // guessing it is how a working URL becomes an unexplainable 404.
1993
+ baseUrl: z.string().url(),
1994
+ protocol: EndpointProtocolSchema.default("openai"),
1995
+ apiKey: z.string().optional(),
1996
+ headers: z.string().optional(),
1997
+ });
1884
1998
  export type McpConfig = z.infer<typeof McpConfigSchema>;
1885
1999
  export type ServiceConfig = z.infer<typeof ServiceConfigSchema>;
1886
2000
  export type IntegrationConfig = z.infer<typeof IntegrationConfigSchema>;
@@ -1894,7 +2008,9 @@ export type IpsecVpnConfig = z.infer<typeof IpsecVpnConfigSchema>;
1894
2008
  export type VpnConfig = z.infer<typeof VpnConfigSchema>;
1895
2009
  export type BrowserPlatform = z.infer<typeof BrowserPlatformSchema>;
1896
2010
  export type BrowserConfig = z.infer<typeof BrowserConfigSchema>;
2011
+ export type HostConfig = z.infer<typeof HostConfigSchema>;
1897
2012
  export type AcpAgentConfig = z.infer<typeof AcpAgentConfigSchema>;
2013
+ export type EndpointConfig = z.infer<typeof EndpointConfigSchema>;
1898
2014
 
1899
2015
  export const CapabilitySchema = z.discriminatedUnion("kind", [
1900
2016
  z.object({ id: entryId, kind: z.literal("devops"), config: z.object({}) }),
@@ -1916,7 +2032,12 @@ export const CapabilitySchema = z.discriminatedUnion("kind", [
1916
2032
  // state (/var/lib/docker) and whatever runs on it make a silent de-privilege more destructive than useful.
1917
2033
  z.object({ id: entryId, kind: z.literal("docker"), config: z.object({}) }),
1918
2034
  z.object({ id: entryId, kind: z.literal("browser"), config: BrowserConfigSchema }),
2035
+ z.object({ id: entryId, kind: z.literal("host"), config: HostConfigSchema }),
1919
2036
  z.object({ id: entryId, kind: z.literal("agent"), config: AcpAgentConfigSchema }),
2037
+ // A model API (EndpointConfigSchema). The id becomes `endpoint/<id>` in the chat picker — the `agent` kind's
2038
+ // precedent, with the prefix because these two are the only capability kinds that mint providers and they
2039
+ // want opposite ability records (an ACP agent owns its own loop; an endpoint runs the full Claude Code one).
2040
+ z.object({ id: entryId, kind: z.literal("endpoint"), config: EndpointConfigSchema }),
1920
2041
  ]);
1921
2042
  export type Capability = z.infer<typeof CapabilitySchema>;
1922
2043
 
@@ -1951,6 +2072,44 @@ export const CapabilitySecretInputSchema = z.object({ id: z.string(), value: z.s
1951
2072
  // which the web surfaces in the terminal panel for the user to complete the sign-in.
1952
2073
  export const CapabilityLoginSchema = z.object({ session: z.string() });
1953
2074
 
2075
+ // ---- hosts: the user's own connected computers (the `host` capability's live half) ----
2076
+ // The manifest says which machines the user INTENDS to have connected; this says which are actually holding a
2077
+ // socket right now. Nothing here is remembered across a daemon restart except the enrollment itself: a machine
2078
+ // is "online" exactly while its WebSocket is attached, so a laptop that closed its lid reads as offline within
2079
+ // a heartbeat rather than staying green until someone asks it to do something.
2080
+
2081
+ // What a machine reports about itself once, at connect (the agent's own `host.describe`, cached until it
2082
+ // reconnects). It is the difference between an agent guessing what is on the box and knowing: the SKILL pack
2083
+ // tells it HOW to drive Windows, this tells it WHICH Windows this is.
2084
+ export const HostFactsSchema = z.object({
2085
+ // The OS's own name for itself — "Windows 11 Pro 24H2", "Ubuntu 24.04.1 LTS".
2086
+ os: z.string(),
2087
+ arch: z.string(),
2088
+ // The shell run_command actually spawns, so the agent writes for the right one from its first command.
2089
+ shell: z.string(),
2090
+ // The machine's home directory, and the default root when the capability declares none.
2091
+ home: z.string(),
2092
+ // Roots in force right now (the capability's `roots`, or [home]) — the agent sees its own boundary.
2093
+ roots: z.array(z.string()),
2094
+ });
2095
+ export type HostFacts = z.infer<typeof HostFactsSchema>;
2096
+
2097
+ export const HostSummarySchema = z.object({
2098
+ // The capability id — the machine's name, and the prefix of its tools (mcp__<id>__run_command).
2099
+ id: z.string(),
2100
+ platform: HostPlatformSchema,
2101
+ online: z.boolean(),
2102
+ // The agent binary's version, so a machine running an old build is visible rather than mysteriously lacking
2103
+ // a tool. Absent until the machine has connected once.
2104
+ version: z.string().optional(),
2105
+ // Epoch ms of the last time this machine held a socket. Absent ⇒ it has not connected since this daemon
2106
+ // booted — liveness is a fact about a socket, so a restart forgets it rather than claiming stale uptime.
2107
+ lastSeen: z.number().optional(),
2108
+ facts: HostFactsSchema.optional(),
2109
+ });
2110
+ export type HostSummary = z.infer<typeof HostSummarySchema>;
2111
+ export const HostsListSchema = z.object({ hosts: z.array(HostSummarySchema) });
2112
+
1954
2113
  // ---- vpn: live tunnel state + connect/disconnect ----
1955
2114
  // The manifest says which VPNs EXIST; this says which are UP right now. Every field is read back from the OS
1956
2115
  // (wg show / ip / openconnect's pidfile / swanctl), never remembered by the daemon — so a tunnel the agent
@@ -3187,6 +3346,35 @@ export const UsageTurnSchema = z.object({
3187
3346
  * would sort turns by how searchable their question was, which is a property of the question. The control
3188
3347
  * arm contains the same unsearchable questions in the same proportion, so they cancel. */
3189
3348
  iqContext: z.boolean().optional(),
3349
+ /* Whether a note was actually PREPENDED on this turn — the companion to `iqContext`, and the answer to the
3350
+ * question that field's design deliberately refuses to answer.
3351
+ *
3352
+ * Keeping the arm on the coin flip is right, and it costs something: the treatment arm contains turns the
3353
+ * treatment never reached, so the delta it yields is diluted by however many those are. Measured over one
3354
+ * day that was four turns in five, which makes the difference between "this mechanism is worth little" and
3355
+ * "this mechanism is worth five times what the number says" — and nothing in the ledger could tell them
3356
+ * apart, because a treated turn and an untreated one in the same arm looked identical.
3357
+ *
3358
+ * So the arm stays intention-to-treat and this records delivery beside it. Together they give both the
3359
+ * unbiased estimate and the rate to divide it by; alone, either one misleads. Absent ⇒ outside the
3360
+ * experiment, exactly as for the arm. */
3361
+ iqContextNote: z.boolean().optional(),
3362
+ /* Characters of the model's own PROSE this turn — the `delta` frames only, so no tool-call arguments and no
3363
+ * thinking. What the terse steer is judged on, and the reason it can be judged at all.
3364
+ *
3365
+ * `outputTokens` cannot serve: measured over a day of real turns it is 91.6% tool-call arguments (an Edit's
3366
+ * old_string and new_string, a Write's whole file body) and 7.8% prose. The steer moves prose. So a fifth
3367
+ * off the model's narration moves the total by 1.6% — against a margin of ±35 points, which is to say the
3368
+ * experiment was structurally unable to see its own treatment, and the number it printed instead was
3369
+ * whichever arm happened to draw the bigger tasks.
3370
+ *
3371
+ * CHARACTERS, not tokens, because the provider bills a total and never breaks it down — a token figure here
3372
+ * would be chars÷4 wearing a unit it had not earned. For a comparison of two arms the constant cancels
3373
+ * anyway, and the honest unit is the one actually counted.
3374
+ *
3375
+ * Absent ⇒ the turn predates this being measured; `armOf` drops it from the population rather than reading
3376
+ * it as a silent turn. */
3377
+ proseChars: z.number().optional(),
3190
3378
  });
3191
3379
  export type UsageTurn = z.infer<typeof UsageTurnSchema>;
3192
3380
 
package/dist/effects.d.ts DELETED
@@ -1,42 +0,0 @@
1
- import type { ConnectorContribution, ExtensionManifest } from "@intentic/extension-api";
2
- import type { CapabilityKind } from "./schemas.js";
3
- export type CapabilityEffect = {
4
- readonly kind: "skill";
5
- readonly name?: string | undefined;
6
- } | {
7
- readonly kind: "secret";
8
- readonly exposure: "agent-env" | "disk";
9
- } | {
10
- readonly kind: "clone";
11
- readonly url?: string | undefined;
12
- } | {
13
- readonly kind: "image";
14
- } | {
15
- readonly kind: "runtime";
16
- readonly level: "net-admin" | "privileged";
17
- } | {
18
- readonly kind: "process";
19
- readonly names: readonly string[];
20
- } | {
21
- readonly kind: "mcp";
22
- } | {
23
- readonly kind: "scaffold";
24
- readonly repos: readonly string[];
25
- } | {
26
- readonly kind: "deploy";
27
- readonly provisions: boolean;
28
- } | {
29
- readonly kind: "trusted-code";
30
- } | {
31
- readonly kind: "profile";
32
- readonly platform: string;
33
- };
34
- export interface CapabilityEffectInput {
35
- readonly kind: CapabilityKind;
36
- readonly id?: string | undefined;
37
- readonly config: Record<string, string | number | boolean | undefined>;
38
- readonly connector?: ConnectorContribution | undefined;
39
- readonly manifest?: ExtensionManifest | undefined;
40
- }
41
- export declare const capabilityEffects: (input: CapabilityEffectInput) => readonly CapabilityEffect[];
42
- //# sourceMappingURL=effects.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"effects.d.ts","sourceRoot":"","sources":["../src/effects.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACxF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAUnD,MAAM,MAAM,gBAAgB,GAGtB;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GAG9D;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,WAAW,GAAG,MAAM,CAAA;CAAE,GAEpE;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GAE7D;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAG1B;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,YAAY,CAAA;CAAE,GAExE;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAE/D;IAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAA;CAAE,GAExB;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAEhE;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;CAAE,GAEzD;IAAE,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;CAAE,GAEjC;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9D,MAAM,WAAW,qBAAqB;IAClC,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAE9B,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAEjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC,CAAC;IAEvE,QAAQ,CAAC,SAAS,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAC;IAEvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAAC;CACrD;AAOD,eAAO,MAAM,iBAAiB,UAAW,qBAAqB,KAAG,SAAS,gBAAgB,EAkFzF,CAAC"}