@intentic/sandbox-contract 1.170.0 → 1.171.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 (84) hide show
  1. package/dist/agent-catalog.d.ts +1 -0
  2. package/dist/agent-catalog.d.ts.map +1 -1
  3. package/dist/agent-catalog.js +1 -0
  4. package/dist/agent-catalog.js.map +1 -1
  5. package/dist/chores/chores.d.ts +45 -0
  6. package/dist/chores/chores.d.ts.map +1 -0
  7. package/dist/chores/chores.js +487 -0
  8. package/dist/chores/chores.js.map +1 -0
  9. package/dist/chores/digest.d.ts +3 -0
  10. package/dist/chores/digest.d.ts.map +1 -0
  11. package/dist/chores/digest.js +0 -0
  12. package/dist/chores/digest.js.map +1 -0
  13. package/dist/chores/index.d.ts +10 -0
  14. package/dist/chores/index.d.ts.map +1 -0
  15. package/dist/chores/index.js +6 -0
  16. package/dist/chores/index.js.map +1 -0
  17. package/dist/chores/probes.d.ts +15 -0
  18. package/dist/chores/probes.d.ts.map +1 -0
  19. package/dist/chores/probes.js +177 -0
  20. package/dist/chores/probes.js.map +1 -0
  21. package/dist/chores/prompt.d.ts +14 -0
  22. package/dist/chores/prompt.d.ts.map +1 -0
  23. package/dist/chores/prompt.js +12 -0
  24. package/dist/chores/prompt.js.map +1 -0
  25. package/dist/chores/verdict.d.ts +20 -0
  26. package/dist/chores/verdict.d.ts.map +1 -0
  27. package/dist/chores/verdict.js +59 -0
  28. package/dist/chores/verdict.js.map +1 -0
  29. package/dist/contracts/agent.contract.d.ts +11 -0
  30. package/dist/contracts/agent.contract.d.ts.map +1 -1
  31. package/dist/contracts/agents.contract.d.ts +2 -0
  32. package/dist/contracts/agents.contract.d.ts.map +1 -1
  33. package/dist/contracts/automations.contract.d.ts +54 -0
  34. package/dist/contracts/automations.contract.d.ts.map +1 -1
  35. package/dist/contracts/chores.contract.d.ts +151 -0
  36. package/dist/contracts/chores.contract.d.ts.map +1 -0
  37. package/dist/contracts/chores.contract.js +8 -0
  38. package/dist/contracts/chores.contract.js.map +1 -0
  39. package/dist/contracts/system.contract.d.ts +14 -14
  40. package/dist/contracts/workspace.contract.d.ts +11 -24
  41. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  42. package/dist/events.d.ts +10 -0
  43. package/dist/events.d.ts.map +1 -1
  44. package/dist/events.js +7 -1
  45. package/dist/events.js.map +1 -1
  46. package/dist/hostnames.d.ts +0 -1
  47. package/dist/hostnames.d.ts.map +1 -1
  48. package/dist/hostnames.js +0 -1
  49. package/dist/hostnames.js.map +1 -1
  50. package/dist/index.d.ts +243 -38
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/schemas.d.ts +590 -40
  55. package/dist/schemas.d.ts.map +1 -1
  56. package/dist/schemas.js +136 -2
  57. package/dist/schemas.js.map +1 -1
  58. package/dist/tunnel-ids.d.ts +2 -0
  59. package/dist/tunnel-ids.d.ts.map +1 -1
  60. package/dist/tunnel-ids.js +2 -0
  61. package/dist/tunnel-ids.js.map +1 -1
  62. package/dist/workspace-state.d.ts.map +1 -1
  63. package/dist/workspace-state.js +5 -0
  64. package/dist/workspace-state.js.map +1 -1
  65. package/package.json +14 -2
  66. package/src/agent-catalog.test.ts +32 -1
  67. package/src/agent-catalog.ts +15 -0
  68. package/src/chores/chores.ts +837 -0
  69. package/src/chores/digest.test.ts +30 -0
  70. package/src/chores/digest.ts +0 -0
  71. package/src/chores/index.ts +9 -0
  72. package/src/chores/probes.test.ts +166 -0
  73. package/src/chores/probes.ts +273 -0
  74. package/src/chores/prompt.ts +64 -0
  75. package/src/chores/verdict.test.ts +394 -0
  76. package/src/chores/verdict.ts +167 -0
  77. package/src/contracts/chores.contract.ts +23 -0
  78. package/src/events.ts +15 -2
  79. package/src/hostnames.ts +4 -6
  80. package/src/index.ts +3 -0
  81. package/src/schemas.ts +383 -13
  82. package/src/tunnel-ids.test.ts +49 -0
  83. package/src/tunnel-ids.ts +24 -0
  84. package/src/workspace-state.ts +5 -0
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { agentContract } from "./contracts/agent.contract.js";
5
5
  import { agentsContract } from "./contracts/agents.contract.js";
6
6
  import { automationsContract } from "./contracts/automations.contract.js";
7
7
  import { capabilitiesContract } from "./contracts/capabilities.contract.js";
8
+ import { choresContract } from "./contracts/chores.contract.js";
8
9
  import { ciContract } from "./contracts/ci.contract.js";
9
10
  import { claudeContract } from "./contracts/claude.contract.js";
10
11
  import { codexContract } from "./contracts/codex.contract.js";
@@ -37,6 +38,7 @@ export { agentContract } from "./contracts/agent.contract.js";
37
38
  export { agentsContract } from "./contracts/agents.contract.js";
38
39
  export { automationsContract } from "./contracts/automations.contract.js";
39
40
  export { capabilitiesContract } from "./contracts/capabilities.contract.js";
41
+ export { choresContract } from "./contracts/chores.contract.js";
40
42
  export { ciContract } from "./contracts/ci.contract.js";
41
43
  export { claudeContract } from "./contracts/claude.contract.js";
42
44
  export { codexContract } from "./contracts/codex.contract.js";
@@ -86,6 +88,7 @@ export const sandboxContract = {
86
88
  agents: agentsContract,
87
89
  automations: automationsContract,
88
90
  capabilities: capabilitiesContract,
91
+ chores: choresContract,
89
92
  ci: ciContract,
90
93
  claude: claudeContract,
91
94
  codex: codexContract,
package/src/schemas.ts CHANGED
@@ -122,6 +122,12 @@ export const AgentTurnSchema = z
122
122
  // 'acceptEdits' auto-accepts file edits; 'bypassPermissions' runs everything. The agent can move
123
123
  // itself between modes mid-turn (EnterPlanMode/ExitPlanMode), which rides back as a `mode` frame.
124
124
  permissionMode: PermissionModeSchema.optional(),
125
+ /* Narrows the turn to these tool names (the SDK option of the same name — not to be confused with the
126
+ * daemon's MCP `tools`, which are servers). Absent ⇒ every tool the runtime has, which is what an
127
+ * owner-driven chat wants. Set by the automation dispatchers from Automation.allowedTools: a wake driven
128
+ * by an OUTSIDE message runs bypassPermissions like any other automation turn, so for a public Doorbell
129
+ * this list is the actual boundary — prompt wording is advice, an empty toolbox is not. */
130
+ allowedTools: z.array(z.string().min(1)).optional(),
125
131
  effort: z.string().optional(),
126
132
  thinking: z.boolean().optional(),
127
133
  // The opt-in editor context chip: what the user is looking at, folded into the prompt daemon-side.
@@ -286,6 +292,7 @@ export type AgentSummary = z.infer<typeof AgentSummarySchema>;
286
292
  // mutated agents back. The browser drops any roster older than the newest it has applied, and holds its own
287
293
  // pending change until a roster at or past the revision that applied it arrives. See useAgents.ts.
288
294
  export const AgentsListSchema = z.object({ agents: z.array(AgentSummarySchema), rev: z.number() });
295
+ export type AgentsList = z.infer<typeof AgentsListSchema>;
289
296
  export const AgentIdSchema = z.object({ id: z.string().min(1) });
290
297
  // archive's input: the agents to take off the board. Absent `ids` ⇒ every finished agent that is archivable
291
298
  // right now (the lane header's "Clear"); unarchive always names its ids (a restore, or a bulk archive's undo).
@@ -353,6 +360,11 @@ export const LandConflictSchema = z.object({
353
360
  repo: z.string(),
354
361
  paths: z.array(LandConflictPathSchema),
355
362
  clean: z.number(),
363
+ // The branch the user's checkout is on — the thing the agent has to rebase onto. Carried because only the
364
+ // daemon can see it: an isolated turn's worktree is mounted over the agent's whole view, so the resolution
365
+ // errand could otherwise only tell it to go and read the name off `git worktree list`. Absent on a detached
366
+ // HEAD or a vanished checkout, where there is no name to give.
367
+ mainBranch: z.string().optional(),
356
368
  });
357
369
  export type LandConflict = z.infer<typeof LandConflictSchema>;
358
370
 
@@ -584,6 +596,7 @@ export const OauthAccountSchema = z.object({
584
596
  });
585
597
  export type OauthAccount = z.infer<typeof OauthAccountSchema>;
586
598
  export const OauthAccountListSchema = z.object({ accounts: z.array(OauthAccountSchema) });
599
+ export type OauthAccountList = z.infer<typeof OauthAccountListSchema>;
587
600
  // Address one account of a provider (disconnect, and the turn's `account`).
588
601
  export const AccountIdSchema = z.object({ id: z.string().min(1) });
589
602
  // Rename one account of a provider whose credential the sandbox owns (Claude, Kimi). Blank ⇒ the daemon falls
@@ -663,6 +676,7 @@ export const SessionSummarySchema = z.object({
663
676
  // own heading is noise, not evidence. See AgentMatchSchema for the same field on the fleet's side.
664
677
  snippet: z.string().optional(),
665
678
  });
679
+ export type SessionSummary = z.infer<typeof SessionSummarySchema>;
666
680
  export const SessionsListSchema = z.object({ sessions: z.array(SessionSummarySchema) });
667
681
 
668
682
  // ---- settings: per-sandbox agent settings (.intentic/settings.json) ----
@@ -1254,24 +1268,37 @@ export type FileDiff = z.infer<typeof FileDiffSchema>;
1254
1268
 
1255
1269
  // ---- workspace tree + files ----
1256
1270
 
1257
- // One node of the full /work filesystem tree the agent sees (untracked + generated files included), distinct
1258
- // from the git-tracked listing. `path` is root-relative with forward slashes so it feeds straight back to the
1259
- // file route. Recursive via zod's getter form (so the type is inferred, not hand-annotated).
1260
- export const WorkspaceTreeEntrySchema = z.object({
1271
+ /* One node of the full /work filesystem tree the agent sees (untracked + generated files included), distinct
1272
+ * from the git-tracked listing. `path` is root-relative with forward slashes so it feeds straight back to the
1273
+ * file route.
1274
+ *
1275
+ * Recursive, and the type is declared rather than inferred. Zod's getter form does infer one, but it collapses
1276
+ * to `{}` below the first level of nesting — so `entry.children[0].name` type-checked as an index-signature
1277
+ * read on both sides of the wire, and the tree walker's own suite was reading a `hidden` field off entries
1278
+ * that has never existed there without the compiler minding. The interface is the contract; the schema
1279
+ * validates against it, and z.ZodType makes a divergence between the two an error here. */
1280
+ export interface WorkspaceTreeEntry {
1281
+ readonly name: string;
1282
+ readonly path: string;
1283
+ readonly type: "file" | "dir";
1284
+ readonly size?: number | undefined;
1285
+ // Ignored-by-tooling (node_modules, .git, .gitignore'd paths, browser profiles): the client grays the row.
1286
+ readonly ignored?: boolean | undefined;
1287
+ // A DIR without `children` was listed but not descended into — because it's ignored, or because the walk's
1288
+ // breadth-first budget stopped above it. Either way the client lazy-loads it via /workspace/children on
1289
+ // expand, so "not loaded yet" and "empty directory" (`children: []`) stay distinguishable.
1290
+ readonly children?: readonly WorkspaceTreeEntry[] | undefined;
1291
+ }
1292
+ export const WorkspaceTreeEntrySchema: z.ZodType<WorkspaceTreeEntry> = z.object({
1261
1293
  name: z.string(),
1262
1294
  path: z.string(),
1263
1295
  type: z.enum(["file", "dir"]),
1264
1296
  size: z.number().optional(),
1265
- // Ignored-by-tooling (node_modules, .git, .gitignore'd paths, browser profiles): the client grays the row.
1266
1297
  ignored: z.boolean().optional(),
1267
- // A DIR without `children` was listed but not descended into — because it's ignored, or because the walk's
1268
- // breadth-first budget stopped above it. Either way the client lazy-loads it via /workspace/children on
1269
- // expand, so "not loaded yet" and "empty directory" (`children: []`) stay distinguishable.
1270
1298
  get children() {
1271
1299
  return z.array(WorkspaceTreeEntrySchema).optional();
1272
1300
  },
1273
1301
  });
1274
- export type WorkspaceTreeEntry = z.infer<typeof WorkspaceTreeEntrySchema>;
1275
1302
  export const WorkspaceTreeSchema = z.object({
1276
1303
  root: z.string(),
1277
1304
  tree: z.array(WorkspaceTreeEntrySchema),
@@ -1332,14 +1359,20 @@ export type WorkspaceClassification = z.infer<typeof WorkspaceClassificationSche
1332
1359
  // The workspace-search wire shape — shared by the daemon's /workspace/search route and the web client.
1333
1360
  // (Implementation detail, not part of the contract: the daemon backs this route with a resident in-process iq
1334
1361
  // engine; the engine is interchangeable behind this shape.) Groups are relevance-ranked (best first, never path
1335
- // order); each hit carries the match-reason tags the fused engines contributed. `start`/`end` are char offsets
1336
- // within `text` so clients highlight without re-finding the needle.
1362
+ // order); each hit carries the match-reason tags the fused engines contributed, and the char spans within `text`
1363
+ // that matched, so clients highlight without re-finding the needle.
1337
1364
  export const WorkspaceSearchQuerySchema = z.object({
1338
1365
  query: z.string().min(2).max(512),
1339
1366
  // Search verbs only — anchor/git verbs (outline, context, log, who, …) are CLI-only surface. Natural language
1340
1367
  // has no verb of its own: `q` classifies the query and answers it semantically when the words call for it.
1341
1368
  mode: z.enum(["q", "find", "files", "def", "refs", "sym", "ast"]).optional(),
1342
1369
  includeIgnored: z.stringbool().optional(),
1370
+ // How `find` reads the query — the three switches every editor's search box has (VSCode: Aa, ab, .*).
1371
+ // `literal` treats it as fixed text instead of a regex; `caseSensitive` off means case-INSENSITIVE, not
1372
+ // ripgrep's smart case.
1373
+ literal: z.stringbool().optional(),
1374
+ word: z.stringbool().optional(),
1375
+ caseSensitive: z.stringbool().optional(),
1343
1376
  limit: z.coerce.number().int().positive().optional(),
1344
1377
  after: z.string().optional(),
1345
1378
  });
@@ -1348,11 +1381,14 @@ export const WorkspaceSearchTagSchema = z.object({
1348
1381
  score: z.number().optional(),
1349
1382
  });
1350
1383
  export type WorkspaceSearchTag = z.infer<typeof WorkspaceSearchTagSchema>;
1384
+ export const WorkspaceSearchSpanSchema = z.object({ start: z.number(), end: z.number() });
1385
+ export type WorkspaceSearchSpan = z.infer<typeof WorkspaceSearchSpanSchema>;
1351
1386
  export const WorkspaceSearchHitSchema = z.object({
1352
1387
  line: z.number(),
1353
1388
  text: z.string(),
1354
- start: z.number().optional(),
1355
- end: z.number().optional(),
1389
+ // Every matched span in `text`, in order — a text search marks all of them, the way an editor does. Empty
1390
+ // where the LINE is the match and no span of it is (a semantic or definition hit reports none).
1391
+ spans: z.array(WorkspaceSearchSpanSchema),
1356
1392
  tags: z.array(WorkspaceSearchTagSchema),
1357
1393
  // Enclosing symbol ("createWidget (fn)") — parent-document context so the reader often needs no follow-up.
1358
1394
  context: z.string().optional(),
@@ -1374,12 +1410,19 @@ export type WorkspaceSearchFreshness = z.infer<typeof WorkspaceSearchFreshnessSc
1374
1410
  export const WorkspaceSearchResultSchema = z.object({
1375
1411
  mode: z.string(),
1376
1412
  total: z.number(),
1413
+ // Files the query matched in total, which `groups` reports only for the page it carries — the count a
1414
+ // results panel puts beside the hit total ("218 results in 61 files").
1415
+ files: z.number(),
1377
1416
  shown: z.number(),
1378
1417
  groups: z.array(WorkspaceSearchGroupSchema),
1379
1418
  freshness: WorkspaceSearchFreshnessSchema,
1380
1419
  truncated: z.boolean(),
1381
1420
  cursor: z.string().optional(),
1382
1421
  hint: z.string().optional(),
1422
+ // What the engine did with the query that the query did not ask for — a pattern rerun as literal text
1423
+ // because it is not valid regex, grep-style escapes rewritten, a language filter that matched no files. The
1424
+ // text surface has always printed this above the results; a JSON caller could not see it at all.
1425
+ note: z.string().optional(),
1383
1426
  // Code-graph neighbors of the top hits (definition anchors + the strongest caller of each).
1384
1427
  related: z.array(z.string()).optional(),
1385
1428
  // Ranked `path:line` anchors that placed but were NOT shown, best first — the answer often sits at rank 5–13,
@@ -2103,12 +2146,130 @@ export const TriggerSchema = z.discriminatedUnion("kind", [
2103
2146
  ]);
2104
2147
  export type Trigger = z.infer<typeof TriggerSchema>;
2105
2148
 
2149
+ /* The Doorbell widget's settings — everything about the embeddable chat that isn't the automation's prompt.
2150
+ * Present only on `webchat` listener automations; the trigger keeps `allowedOrigins` because that one is the
2151
+ * admission gate the message route reads, not a rendering choice.
2152
+ *
2153
+ * Split deliberately into what the WIDGET may read (title/greeting/accent/position/access/googleClientId/
2154
+ * turnstileSiteKey — all public by construction, they ship to a stranger's browser) and what only the daemon
2155
+ * may read (turnstileSecret). GET /webchat/<id>/config serves the first group by naming it, never by omitting
2156
+ * the second: a field added here is invisible to the widget until it is listed there. */
2157
+ export const WebchatConfigSchema = z.object({
2158
+ // `public` admits anyone; `google` refuses a message that carries no verifiable Google ID token. Absent ⇒
2159
+ // public — a Doorbell with no access setting is the anonymous support box it looks like.
2160
+ access: z.enum(["public", "google"]).optional(),
2161
+ // Ask an anonymous visitor for a display name before the first message. Cosmetic: the name is typed, so it
2162
+ // reaches the model as untrusted `displayName`, never as identity.
2163
+ requireName: z.boolean().optional(),
2164
+ /* The bot ceiling. `turnstile` is Cloudflare's (invisible, needs the site's own keys); `pow` is a
2165
+ * hashcash-style challenge the daemon issues and the widget solves in a worker, so a site with no
2166
+ * Cloudflare account still has something. Absent ⇒ off: the origin allowlist and the rate limit are then
2167
+ * the whole boundary, which is the right default for an internal or invite-only page. */
2168
+ antiBot: z.enum(["turnstile", "pow"]).optional(),
2169
+ turnstileSiteKey: z.string().optional(),
2170
+ turnstileSecret: z.string().optional(),
2171
+ // The site's OWN Google OAuth web client id. It cannot be intentic's: Google Identity Services only issues
2172
+ // a token to an authorized JavaScript origin, and intentic's client can't list every customer domain.
2173
+ googleClientId: z.string().optional(),
2174
+ // Widget chrome. `accent` is any CSS colour the host page can render; `position` picks the launcher corner.
2175
+ title: z.string().max(80).optional(),
2176
+ greeting: z.string().max(500).optional(),
2177
+ accent: z.string().max(40).optional(),
2178
+ position: z.enum(["top-right", "top-left", "bottom-right", "bottom-left"]).optional(),
2179
+ /* Two ceilings on top of the route's fixed per-minute window, because a public endpoint's real exposure is
2180
+ * cost, not request rate: `dailyMessageMax` caps the whole automation per UTC day, `conversationMessageMax`
2181
+ * caps one visitor thread for its lifetime.
2182
+ *
2183
+ * `dailyMessageMax` absent ⇒ WEBCHAT_DAILY_MAX_DEFAULT, not uncapped. Every message here is an agent turn
2184
+ * billed to the owner, and the per-minute window bounds the RATE without bounding the DAY — twenty a minute,
2185
+ * sustained, is tens of thousands of turns before anyone notices. A Doorbell nobody configured should not be
2186
+ * able to spend that, so the safe number is the one you get for free and the owner raises it deliberately.
2187
+ * `conversationMessageMax` stays optional-means-uncapped: it is per visitor thread, which the daily ceiling
2188
+ * already bounds in aggregate. */
2189
+ dailyMessageMax: z.number().int().positive().optional(),
2190
+ conversationMessageMax: z.number().int().positive().optional(),
2191
+ // (WEBCHAT_DAILY_MAX_DEFAULT, below the schema, is the number `dailyMessageMax` falls back to.)
2192
+ // How long a visitor thread keeps resuming the same conversation before the next message starts a fresh
2193
+ // one. Absent ⇒ WEBCHAT_SESSION_TTL_MS (the daemon's default).
2194
+ sessionTtlMinutes: z.number().int().positive().optional(),
2195
+ });
2196
+ export type WebchatConfig = z.infer<typeof WebchatConfigSchema>;
2197
+
2198
+ /* The daily agent-turn ceiling a Doorbell gets when its owner sets none. Lives here rather than beside the
2199
+ * route that enforces it because both ends need the number: the daemon to apply it, and the automation editor
2200
+ * to show the owner what they are already protected by (an invisible limit is one people hit and file as a bug).
2201
+ *
2202
+ * 200 is chosen to be irrelevant to real support traffic and decisive against a script. A Doorbell answering
2203
+ * two hundred questions in one UTC day is a busy one; a scripted flood reaches that in ten seconds and then
2204
+ * stops costing anything. */
2205
+ export const WEBCHAT_DAILY_MAX_DEFAULT = 200;
2206
+
2207
+ /* ---- the widget wire: three shapes GET /webchat/<id>/config, GET …/challenge and POST …/message speak ----
2208
+ *
2209
+ * They live here, beside the stored config they derive from, because the Doorbell widget is a SECOND client of
2210
+ * this daemon — a bundle running on a stranger's page — and the reason this package exists is that both ends of
2211
+ * a wire read one definition. The widget imports these as types only (`import type`), so zod never reaches a
2212
+ * visitor's browser. */
2213
+
2214
+ // What the widget is told about itself. Fully RESOLVED — every default is applied daemon-side, so the widget
2215
+ // carries no fallback logic and one place decides what an unset field means. Everything here is public by
2216
+ // construction: it ships to any browser that can reach the endpoint from an allowed origin.
2217
+ export const WebchatPublicConfigSchema = z.object({
2218
+ automationId: z.string(),
2219
+ title: z.string(),
2220
+ greeting: z.string(),
2221
+ accent: z.string(),
2222
+ position: z.enum(["top-right", "top-left", "bottom-right", "bottom-left"]),
2223
+ access: z.enum(["public", "google"]),
2224
+ requireName: z.boolean(),
2225
+ // "off" is spelled out rather than left absent: the widget branches on this, and a missing field that means
2226
+ // "no challenge" is the kind of default that turns one serialization bug into an open door.
2227
+ antiBot: z.enum(["turnstile", "pow", "off"]),
2228
+ turnstileSiteKey: z.string().optional(),
2229
+ googleClientId: z.string().optional(),
2230
+ });
2231
+ export type WebchatPublicConfig = z.infer<typeof WebchatPublicConfigSchema>;
2232
+
2233
+ // A proof-of-work challenge: find a nonce whose SHA-256 of `${salt}:${nonce}` starts with `difficulty` zero
2234
+ // bits. Issued per visitor conversation, spent on its first message.
2235
+ export const WebchatChallengeSchema = z.object({ salt: z.string(), difficulty: z.number().int().positive() });
2236
+ export type WebchatChallenge = z.infer<typeof WebchatChallengeSchema>;
2237
+
2238
+ // One visitor message. `conversationId` is the widget's own localStorage id — it threads the visitor's messages
2239
+ // into ONE sandbox conversation, so it is the thread key, not a secret (anyone can mint one; the origin
2240
+ // allowlist, the challenge and the rate limit are the gate).
2241
+ export const WebchatMessageSchema = z.object({
2242
+ conversationId: z.string().min(1).max(200),
2243
+ content: z.string().min(1),
2244
+ // What the visitor TYPED as their name. Never identity — it reaches the model tagged as unverified, and a
2245
+ // signed-in visitor's verified name comes from the ID token instead.
2246
+ displayName: z.string().max(200).optional(),
2247
+ // A Google ID token from the site's own client id, verified daemon-side against Google's JWKS.
2248
+ idToken: z.string().optional(),
2249
+ // The anti-bot answer, whichever kind the config asked for. Checked once per conversation, not per message.
2250
+ turnstileToken: z.string().optional(),
2251
+ powNonce: z.string().optional(),
2252
+ // The widget's own transcript, sent ONLY on the first message of a thread — after that the sandbox
2253
+ // conversation resumes and carries its own context.
2254
+ history: z
2255
+ .array(z.object({ author: z.string().optional(), content: z.string() }))
2256
+ .max(50)
2257
+ .optional(),
2258
+ });
2259
+ export type WebchatMessage = z.infer<typeof WebchatMessageSchema>;
2260
+
2106
2261
  export const AutomationSchema = z.object({
2107
2262
  id: entryId,
2108
2263
  trigger: TriggerSchema,
2109
2264
  // Shell command run in the workspace root before waking; exit 0 ⇒ wake, non-zero ⇒ the run is "skipped".
2110
2265
  guard: z.string().min(1).optional(),
2111
2266
  prompt: z.string().min(1),
2267
+ // The Doorbell widget's settings — `webchat` listener automations only, ignored on every other trigger.
2268
+ webchat: WebchatConfigSchema.optional(),
2269
+ // The tool names this automation's wake may call (AgentTurnSchema.allowedTools). The reason it exists: a
2270
+ // webchat automation is driven by strangers, and an automation turn runs bypassPermissions by default — so
2271
+ // the allowlist, not the prompt's wording, is what bounds what an injected instruction can reach.
2272
+ allowedTools: z.array(z.string().min(1)).optional(),
2112
2273
  // Which provider adapter serves the wake; absent ⇒ claude. Same dispatch as a chat turn (AgentTurnSchema.agent).
2113
2274
  agent: AgentProviderSchema.optional(),
2114
2275
  // Which harness (agentic loop) runs the wake; absent ⇒ native. Same semantics as AgentTurnSchema.harness.
@@ -2664,6 +2825,7 @@ export const InfoSchema = z.object({
2664
2825
  latest: z.string().optional(),
2665
2826
  updateAvailable: z.boolean().optional(),
2666
2827
  });
2828
+ export type Info = z.infer<typeof InfoSchema>;
2667
2829
 
2668
2830
  // A daemon-minted session (system.session): the steady-state browser credential, exchanged for a verified
2669
2831
  // Google ID token so Google UI is a sign-in moment instead of an hourly renewal. `expiresAt` is epoch ms —
@@ -2970,3 +3132,211 @@ export const PushConfigQuerySchema = z.object({ endpoint: z.string().url().optio
2970
3132
  // the question being asked. A count separates "your OS swallowed it" from "nothing was sent at all".
2971
3133
  export const PushTestSchema = z.object({ delivered: z.number().int().nonnegative() });
2972
3134
  export type PushTest = z.infer<typeof PushTestSchema>;
3135
+
3136
+ // ---- maintenance: the standing evidence a chore is decided from ----
3137
+
3138
+ /* THE DAEMON SERVES FACTS; THE BROWSER DECIDES. Everything below is measurement — what a tool reported, what the
3139
+ * manifests say, when a chore last ran. Not one field here says "you should do something", and that is the whole
3140
+ * boundary: which chore is DUE is computed by @intentic/sandbox-contract/chores, which both the Maintenance view and its rail
3141
+ * badge run, so the number on the tile and the reason in the panel can never disagree. Put the verdict on the wire
3142
+ * instead and a daemon one image behind would be quietly arguing with the browser about what needs doing.
3143
+ *
3144
+ * The split inside the evidence is by COST, not by subject:
3145
+ * probes subprocesses (pnpm outdated, pnpm audit, knip, jscpd) — minutes, so they are cached on disk with a
3146
+ * TTL and refreshed by a background runner. A route hit never waits on one.
3147
+ * signals things the daemon already knows — the resident iq index's health ranking, the package manifests it
3148
+ * reads for the dependency graph, its own node version. Recomputed per request; all of it is cheap. */
3149
+
3150
+ export const PROBE_IDS = ["outdated", "audit", "knip", "jscpd"] as const;
3151
+ export const ProbeIdSchema = z.enum(PROBE_IDS);
3152
+ export type ProbeId = z.infer<typeof ProbeIdSchema>;
3153
+
3154
+ // One dependency the registry has moved past. `kind` is the SEMVER distance, which is the whole reason this is
3155
+ // not one number: forty patch releases behind is a morning's work and one major is a project.
3156
+ export const OutdatedPackageSchema = z.object({
3157
+ name: z.string(),
3158
+ current: z.string(),
3159
+ latest: z.string(),
3160
+ kind: z.enum(["major", "minor", "patch"]),
3161
+ // "dependencies" / "devDependencies" / "optionalDependencies" — a dev-only major is a different risk.
3162
+ section: z.string(),
3163
+ });
3164
+ export type OutdatedPackage = z.infer<typeof OutdatedPackageSchema>;
3165
+
3166
+ // One advisory, reduced to what a decision needs. No CVSS vector and no reference list: those are for reading on
3167
+ // the advisory page, and carrying them would put a kilobyte of prose per finding on every poll of this route.
3168
+ export const AdvisorySchema = z.object({
3169
+ name: z.string(),
3170
+ severity: z.enum(["critical", "high", "moderate", "low", "info"]),
3171
+ title: z.string(),
3172
+ // The range that fixes it, when the advisory names one. Absent ⇒ no patch published yet, which is the case
3173
+ // where a chore must NOT offer to bump and say so instead.
3174
+ patched: z.string().optional(),
3175
+ // Whether it reaches a production dependency path. A build-time-only tool's transitive CVE is a different
3176
+ // problem, and the chore's prompt says so rather than treating every advisory alike.
3177
+ dev: z.boolean(),
3178
+ });
3179
+ export type Advisory = z.infer<typeof AdvisorySchema>;
3180
+
3181
+ // knip's counts, by the kind of thing it found unreachable. Counts plus a sample rather than the full list: the
3182
+ // agent re-runs knip itself against the live tree (a list from a probe hours old would send it at files that are
3183
+ // already gone), so what travels here only has to be enough to decide whether the turn is worth starting.
3184
+ export const DeadCodeSchema = z.object({
3185
+ files: z.number().int().nonnegative(),
3186
+ exports: z.number().int().nonnegative(),
3187
+ types: z.number().int().nonnegative(),
3188
+ dependencies: z.number().int().nonnegative(),
3189
+ devDependencies: z.number().int().nonnegative(),
3190
+ // A handful of the unreferenced files, for the panel to show instead of asking the reader to take "31" on faith.
3191
+ sample: z.array(z.string()),
3192
+ });
3193
+ export type DeadCode = z.infer<typeof DeadCodeSchema>;
3194
+
3195
+ // jscpd's headline plus the biggest clones. `percentage` is of scanned lines, which is the figure a threshold is
3196
+ // worth setting against — a clone COUNT grows with the repo and would mean something different every quarter.
3197
+ export const DuplicationSchema = z.object({
3198
+ percentage: z.number(),
3199
+ clones: z.number().int().nonnegative(),
3200
+ top: z.array(z.object({ lines: z.number().int().nonnegative(), first: z.string(), second: z.string() })),
3201
+ });
3202
+ export type Duplication = z.infer<typeof DuplicationSchema>;
3203
+
3204
+ /* One probe's cached result. The three states are deliberately distinct, because a panel that collapses them
3205
+ * lies about the most important case:
3206
+ * ok the tool ran and reported. `facts` carries its findings — including "nothing found", which is
3207
+ * a real answer and the one that keeps a chore quiet.
3208
+ * unavailable the tool is not part of this repo (knip is not a devDependency, there is no lockfile to audit).
3209
+ * Not a failure and not evidence of health: the chore renders as unmeasured, and can never badge.
3210
+ * failed the tool ran and broke — a network-less audit, a jscpd that ran out of memory. Says so, with
3211
+ * the tail of what it printed, rather than reading as "clean".
3212
+ * Merging `unavailable` into `ok`-with-zeros is how a maintenance surface ends up reporting a green repository
3213
+ * it has never actually measured. */
3214
+ export const ProbeStateSchema = z.enum(["ok", "unavailable", "failed"]);
3215
+ export type ProbeState = z.infer<typeof ProbeStateSchema>;
3216
+
3217
+ // The findings, discriminated by which probe produced them. Absent while the probe has never completed, and on
3218
+ // `unavailable`/`failed` — a reader must go through `state` to reach facts, so there is no shape in which a
3219
+ // missing measurement can be mistaken for a zero.
3220
+ export const ProbeFactsSchema = z.discriminatedUnion("id", [
3221
+ z.object({ id: z.literal("outdated"), packages: z.array(OutdatedPackageSchema) }),
3222
+ z.object({ id: z.literal("audit"), advisories: z.array(AdvisorySchema) }),
3223
+ z.object({ id: z.literal("knip"), deadCode: DeadCodeSchema }),
3224
+ z.object({ id: z.literal("jscpd"), duplication: DuplicationSchema }),
3225
+ ]);
3226
+ export type ProbeFacts = z.infer<typeof ProbeFactsSchema>;
3227
+
3228
+ export const ProbeResultSchema = z.object({
3229
+ id: ProbeIdSchema,
3230
+ state: ProbeStateSchema,
3231
+ // When the probe last COMPLETED — the age the panel shows, and what the runner's TTL is measured from.
3232
+ ranAt: z.number(),
3233
+ // How long it took. Shown because a seven-minute jscpd is why the tier-2 refresh is weekly, and a reader
3234
+ // deciding whether to force a refresh deserves to know what they are asking for.
3235
+ tookMs: z.number().int().nonnegative(),
3236
+ facts: ProbeFactsSchema.optional(),
3237
+ // Why it is unavailable, or how it failed — the tail of the tool's own output. Never invented here.
3238
+ reason: z.string().optional(),
3239
+ });
3240
+ export type ProbeResult = z.infer<typeof ProbeResultSchema>;
3241
+
3242
+ // One workspace package as its manifest declares it — what the daemon already reads to build the dependency
3243
+ // graph, carried through so chores can reason about the repo's own shape without a probe. `documented` is the
3244
+ // one derived field: whether docs/architecture/<dir>/doc.md exists, a stat per package.
3245
+ export const ChorePackageSchema = z.object({
3246
+ dir: z.string(),
3247
+ name: z.string(),
3248
+ // The manifest's `engines` map, verbatim — the runtime chore compares it against what the daemon is running.
3249
+ engines: z.record(z.string(), z.string()).optional(),
3250
+ dependencies: z.array(z.string()),
3251
+ devDependencies: z.array(z.string()),
3252
+ documented: z.boolean(),
3253
+ });
3254
+ export type ChorePackage = z.infer<typeof ChorePackageSchema>;
3255
+
3256
+ /* The cheap half of the evidence: what the daemon knows without starting anything. `hotspots` and `keyModules`
3257
+ * are the same rankings GET /workspace/health serves, capped tighter — a chore only ever asks whether a file has
3258
+ * ENTERED the top of the ranking, so a leaderboard is enough and a full report per repo per poll is not. */
3259
+ /* WHAT THIS REPOSITORY IS MADE OF — the facts that decide whether a chore is a QUESTION worth asking of it at
3260
+ * all, as opposed to whether the answer happens to be yes.
3261
+ *
3262
+ * The distinction is the difference between a maintenance surface that reads as attentive and one that reads as
3263
+ * generic. "Re-read the documentation against the code" in a repository with no documentation is not a chore
3264
+ * that is currently clear — it is a chore that will never make sense here, and showing it teaches the owner that
3265
+ * this list was written by someone who had not looked. Same for a Docker chore with no Dockerfile, or a CI chore
3266
+ * with no pipeline.
3267
+ *
3268
+ * These are all paths, deliberately: presence of a FILE is checkable, cheap, and cannot be argued with, which is
3269
+ * the same evidence-over-identity rule the extension activation facts follow. Every field is a list rather than a
3270
+ * boolean where the paths themselves are worth showing — a chore that says "not applicable: no Dockerfile" is
3271
+ * useful, and one that says "3 Dockerfiles: ./Dockerfile, _apps/web/Dockerfile, …" is more so. */
3272
+ export const ChoreShapeSchema = z.object({
3273
+ // Architecture documents that actually exist (docs/architecture/**/doc.md), capped — the count is what
3274
+ // matters, and the drift survey needs to know there is something to re-read.
3275
+ docs: z.array(z.string()),
3276
+ dockerfiles: z.array(z.string()),
3277
+ // CI pipeline definitions: .github/workflows/*.yml, .gitlab-ci.yml, and the other single-file conventions.
3278
+ ci: z.array(z.string()),
3279
+ // Whether dependencies are resolved to a lockfile — what makes an audit mean anything.
3280
+ lockfile: z.boolean(),
3281
+ // A package.json at the repo root. The gate for every chore whose subject is the JavaScript dependency tree:
3282
+ // a Rust or Go repository has no majors to be behind on and no engines field to be pinned by, and offering it
3283
+ // those chores would be this surface guessing at what it is looking at.
3284
+ packageManifest: z.boolean(),
3285
+ });
3286
+ export type ChoreShape = z.infer<typeof ChoreShapeSchema>;
3287
+
3288
+ export const ChoreSignalsSchema = z.object({
3289
+ packages: z.array(ChorePackageSchema),
3290
+ shape: ChoreShapeSchema,
3291
+ hotspots: z.array(WorkspaceHotspotSchema),
3292
+ keyModules: z.array(WorkspaceKeyModuleSchema),
3293
+ totals: z.object({ files: z.number(), symbols: z.number(), complexity: z.number(), hotspots: z.number() }),
3294
+ // Whether the index these rankings came from is current. A chore must not fire on a half-built index, and
3295
+ // this is how the browser knows to hold its verdict rather than act on a partial ranking.
3296
+ indexed: z.boolean(),
3297
+ });
3298
+ export type ChoreSignals = z.infer<typeof ChoreSignalsSchema>;
3299
+
3300
+ // What a finished chore turn left behind — written by the agent, read back to decide whether the chore is still
3301
+ // due. `clean` is the load-bearing one: an agent that looked and found the tool's findings to be false positives
3302
+ // must be able to say so, or the next poll starts the same turn again forever.
3303
+ export const ChoreOutcomeSchema = z.enum(["acted", "reported", "clean"]);
3304
+ export type ChoreOutcome = z.infer<typeof ChoreOutcomeSchema>;
3305
+
3306
+ /* One chore's history in one repo. The DIGEST is what makes this a debounce rather than a suppression: it is a
3307
+ * hash of the evidence that was standing when the turn ran, so a chore whose evidence has since changed is due
3308
+ * again on its own merits while one whose evidence is unchanged stays quiet — with the run still visible in the
3309
+ * panel, saying when it ran and what it concluded. Nothing here can hide a chore from the view; it only decides
3310
+ * whether the rail is allowed to speak. */
3311
+ export const ChoreLedgerEntrySchema = z.object({
3312
+ repo: z.string(),
3313
+ chore: z.string(),
3314
+ ranAt: z.number(),
3315
+ runId: z.string(),
3316
+ outcome: ChoreOutcomeSchema,
3317
+ digest: z.string(),
3318
+ // Set by the owner from the panel — the chore stays visible and stays out of the badge until this passes.
3319
+ // Distinct from opting out, which is the absence of the chore from `enabled` in the sandbox's settings.
3320
+ snoozedUntil: z.number().optional(),
3321
+ });
3322
+ export type ChoreLedgerEntry = z.infer<typeof ChoreLedgerEntrySchema>;
3323
+
3324
+ // GET /chores — every discovered repo's standing evidence, plus the ledger, in one read. One route rather than
3325
+ // one per repo because the rail badge scans ALL of them on a timer, and N requests a minute to answer "is
3326
+ // anything due" is the kind of poll that shows up in a battery graph.
3327
+ export const ChoresReportSchema = z.object({
3328
+ repos: z.array(z.object({ repo: z.string(), probes: z.array(ProbeResultSchema), signals: ChoreSignalsSchema })),
3329
+ ledger: z.array(ChoreLedgerEntrySchema),
3330
+ // The daemon's own runtime, for the chore that asks whether this sandbox is running something end-of-life.
3331
+ // Read off the process rather than a manifest: what is INSTALLED is the fact that matters, and an `engines`
3332
+ // range is a wish.
3333
+ node: z.string(),
3334
+ });
3335
+ export type ChoresReport = z.infer<typeof ChoresReportSchema>;
3336
+
3337
+ // POST /chores/probe — force one probe to re-run now, ahead of its TTL. Returns immediately; the runner does the
3338
+ // work and the next GET /chores carries the result, the same shape the panel already polls.
3339
+ export const ChoreProbeRequestSchema = z.object({ repo: z.string().min(1), id: ProbeIdSchema });
3340
+ // POST /chores/ledger — record a run, or snooze. Written daemon-side rather than by the browser so a chore turn
3341
+ // started from anywhere (the panel, an automation, the agent itself) lands in one ledger.
3342
+ export const ChoreLedgerWriteSchema = ChoreLedgerEntrySchema;
@@ -0,0 +1,49 @@
1
+ import { expect, test } from "vitest";
2
+ import { hostSshIdFromToken, PORT_SLOT_COUNT, portSlotsFromToken, sandboxIdFromToken } from "./tunnel-ids.js";
3
+
4
+ const TOKEN = "connect-token-one";
5
+ const OTHER = "connect-token-two";
6
+
7
+ test("the sandbox id is a stable 12-hex digest of the connect token, and absent without one", () => {
8
+ expect(sandboxIdFromToken(TOKEN)).toMatch(/^[0-9a-f]{12}$/);
9
+ expect(sandboxIdFromToken(TOKEN)).toBe(sandboxIdFromToken(TOKEN));
10
+ expect(sandboxIdFromToken(OTHER)).not.toBe(sandboxIdFromToken(TOKEN));
11
+ expect(sandboxIdFromToken("")).toBeUndefined();
12
+ });
13
+
14
+ test("a host ssh id is salted per host, so two deploy targets never collide", () => {
15
+ expect(hostSshIdFromToken(TOKEN, "web-1")).toMatch(/^[0-9a-f]{12}$/);
16
+ expect(hostSshIdFromToken(TOKEN, "web-1")).not.toBe(hostSshIdFromToken(TOKEN, "web-2"));
17
+ });
18
+
19
+ test("port slots are a fixed-size pool of DNS-safe labels, stable per token", () => {
20
+ const slots = portSlotsFromToken(TOKEN);
21
+ // Fixed size: the pool is the cap on preview DNS records a sandbox costs the shared zone.
22
+ expect(slots).toHaveLength(PORT_SLOT_COUNT);
23
+ // Every label must survive as one DNS label AND pass the platform's mint filter
24
+ // (/^(preview|port)-[a-z0-9][a-z0-9-]*$/, ≤50 chars), which `port-<slot>` has to satisfy.
25
+ for (const slot of slots) {
26
+ expect(slot).toMatch(/^[0-9a-f]{12}$/);
27
+ expect(`port-${slot}`).toMatch(/^port-[a-z0-9][a-z0-9-]*$/);
28
+ expect(`port-${slot}`.length).toBeLessThanOrEqual(50);
29
+ }
30
+ // Distinct, or two forwards would fight over one hostname.
31
+ expect(new Set(slots).size).toBe(PORT_SLOT_COUNT);
32
+ // Stable: the daemon and the platform derive these independently and must agree, and a restart has to
33
+ // land on the records already minted rather than orphaning them.
34
+ expect(portSlotsFromToken(TOKEN)).toEqual(slots);
35
+ });
36
+
37
+ /* The whole point of the salt. The sandbox id is public — it is the leading label of the sandbox's own URL and
38
+ * of every preview link its owner has shared — so anything derived from the id ALONE is derivable by whoever
39
+ * holds one of those links. Slots must not be: knowing a sandbox's id must not tell you where its forwarded
40
+ * ports live. */
41
+ test("slots are not derivable from the sandbox id — only from the token behind it", () => {
42
+ expect(portSlotsFromToken(OTHER)).not.toEqual(portSlotsFromToken(TOKEN));
43
+ // No slot leaks the id it will be paired with in `port-<slot>-<sandboxId>`.
44
+ const id = sandboxIdFromToken(TOKEN);
45
+ expect(id).toBeDefined();
46
+ expect(portSlotsFromToken(TOKEN)).not.toContain(id);
47
+ // And none of them is the old fixed alphabet, which is what made the hostnames guessable.
48
+ expect(portSlotsFromToken(TOKEN).some((slot) => slot.length === 1)).toBe(false);
49
+ });
package/src/tunnel-ids.ts CHANGED
@@ -17,3 +17,27 @@ export const sandboxIdFromToken = (connectToken: string): string | undefined =>
17
17
  // own ssh-<id>.<zone> (no collision across hosts). Shared by the CLI (createHostSshTunnel) and the platform API
18
18
  // (provisionHostSshTunnel), which MUST derive the identical id.
19
19
  export const hostSshIdFromToken = (connectToken: string, hostName: string): string => sha256Hex(`${connectToken}:${hostName}`).slice(0, 12);
20
+
21
+ /* How many port-forward slots a sandbox has. Eight is enough for a monorepo's worth of concurrent dev servers,
22
+ * and it is the hard cap on preview DNS records a sandbox can ever cost the shared intentic zone. */
23
+ export const PORT_SLOT_COUNT = 8;
24
+
25
+ /* THE PORT-FORWARD SLOT LABELS — the `port-<slot>` half of `port-<slot>-<sandboxId>.<zone>`.
26
+ *
27
+ * These were the letters a…h, and that was the hole: a forwarded port's hostname was then a pure function of the
28
+ * sandbox id, and the sandbox id is not a secret — it is the leading label of the URL the owner uses daily and
29
+ * of every preview link they have ever shared. So anyone who had seen ONE preview link could poll eight fixed
30
+ * names forever and catch whatever the owner forwarded, at any point in the future. The Ports view says a
31
+ * forwarded port is public, and it is; what it could not say was that "public" meant eight guessable URLs.
32
+ *
33
+ * Salting with the connect token fixes that without costing anything the letters bought. Still exactly eight
34
+ * records (the reason slots exist at all — the intentic-provided zone mints per label, and dev servers churn
35
+ * ephemeral ports far faster than DNS should), still stable across restarts so a slot's record stays warm, and
36
+ * still derivable with no coordination by every party that already holds the token: the daemon that forwards,
37
+ * and the platform that mints the DNS. A party without the token has no business predicting these names.
38
+ *
39
+ * The browser is deliberately NOT one of those parties — it never derives a port hostname, it reads `previewUrl`
40
+ * off the daemon's response — which is why this can live here, in the node-only half of the contract, next to
41
+ * the digest it shares with sandboxIdFromToken. */
42
+ export const portSlotsFromToken = (connectToken: string): readonly string[] =>
43
+ Array.from({ length: PORT_SLOT_COUNT }, (_, index) => sha256Hex(`${connectToken}:port:${index}`).slice(0, 12));
@@ -77,6 +77,11 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = [
77
77
  * Both entries below are outside that by design, so an empty set is the honest record — naming a key no
78
78
  * query uses would put the drift this table exists to remove straight back into it. Each says which
79
79
  * constraint would have to move first, so the next reader doesn't re-derive it. */
80
+ {
81
+ path: ".intentic/webchat-sessions.json",
82
+ invalidates: [],
83
+ why: "Doorbell thread bookkeeping (visitor thread → sandbox conversation + provider session), written on EVERY visitor message. Nothing in the browser reads it: what a visitor's chat produces is a conversation, and the fleet board already learns about that from the agent registry's own push. Naming a key here would bill every connected browser a refetch per inbound message — the request storm this table's own note warns about — to refresh nothing it can see.",
84
+ },
80
85
  {
81
86
  path: ".intentic/extension-settings.json",
82
87
  invalidates: [],