@intentic/sandbox-contract 1.169.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 (92) 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 +2 -1
  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 +22 -0
  30. package/dist/contracts/agent.contract.d.ts.map +1 -1
  31. package/dist/contracts/agent.contract.js +2 -1
  32. package/dist/contracts/agent.contract.js.map +1 -1
  33. package/dist/contracts/agents.contract.d.ts +2 -0
  34. package/dist/contracts/agents.contract.d.ts.map +1 -1
  35. package/dist/contracts/automations.contract.d.ts +54 -0
  36. package/dist/contracts/automations.contract.d.ts.map +1 -1
  37. package/dist/contracts/chores.contract.d.ts +151 -0
  38. package/dist/contracts/chores.contract.d.ts.map +1 -0
  39. package/dist/contracts/chores.contract.js +8 -0
  40. package/dist/contracts/chores.contract.js.map +1 -0
  41. package/dist/contracts/extensions.contract.d.ts +7 -0
  42. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  43. package/dist/contracts/extensions.contract.js +2 -1
  44. package/dist/contracts/extensions.contract.js.map +1 -1
  45. package/dist/contracts/system.contract.d.ts +14 -14
  46. package/dist/contracts/workspace.contract.d.ts +11 -24
  47. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  48. package/dist/events.d.ts +10 -0
  49. package/dist/events.d.ts.map +1 -1
  50. package/dist/events.js +7 -1
  51. package/dist/events.js.map +1 -1
  52. package/dist/hostnames.d.ts +0 -1
  53. package/dist/hostnames.d.ts.map +1 -1
  54. package/dist/hostnames.js +0 -1
  55. package/dist/hostnames.js.map +1 -1
  56. package/dist/index.d.ts +261 -38
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/schemas.d.ts +618 -40
  61. package/dist/schemas.d.ts.map +1 -1
  62. package/dist/schemas.js +145 -2
  63. package/dist/schemas.js.map +1 -1
  64. package/dist/tunnel-ids.d.ts +2 -0
  65. package/dist/tunnel-ids.d.ts.map +1 -1
  66. package/dist/tunnel-ids.js +2 -0
  67. package/dist/tunnel-ids.js.map +1 -1
  68. package/dist/workspace-state.d.ts.map +1 -1
  69. package/dist/workspace-state.js +9 -0
  70. package/dist/workspace-state.js.map +1 -1
  71. package/package.json +14 -2
  72. package/src/agent-catalog.test.ts +32 -1
  73. package/src/agent-catalog.ts +25 -7
  74. package/src/chores/chores.ts +837 -0
  75. package/src/chores/digest.test.ts +30 -0
  76. package/src/chores/digest.ts +0 -0
  77. package/src/chores/index.ts +9 -0
  78. package/src/chores/probes.test.ts +166 -0
  79. package/src/chores/probes.ts +273 -0
  80. package/src/chores/prompt.ts +64 -0
  81. package/src/chores/verdict.test.ts +394 -0
  82. package/src/chores/verdict.ts +167 -0
  83. package/src/contracts/agent.contract.ts +14 -1
  84. package/src/contracts/chores.contract.ts +23 -0
  85. package/src/contracts/extensions.contract.ts +5 -0
  86. package/src/events.ts +15 -2
  87. package/src/hostnames.ts +4 -6
  88. package/src/index.ts +3 -0
  89. package/src/schemas.ts +427 -19
  90. package/src/tunnel-ids.test.ts +49 -0
  91. package/src/tunnel-ids.ts +24 -0
  92. package/src/workspace-state.ts +13 -0
package/src/hostnames.ts CHANGED
@@ -43,18 +43,16 @@ export const CATCH_ALL = { service: "http_status:404" } as const;
43
43
  // covers exactly one level), where <panel> is `<repo>` or `<repo>--<app>` and <sandboxId> pins the hostname to
44
44
  // this sandbox (the shared intentic zone hosts many sandboxes; without the id two users' panels would collide).
45
45
  // Port-forward scheme: `port-<slot>-<sandboxId>.<zone>` — the same shape with a `port-` prefix, where <slot>
46
- // is one of the sandbox's fixed forward slots (see PORT_SLOTS), not the port number itself: slots keep the
47
- // intentic-provided path's minted routes bounded and warm while dev servers churn ephemeral ports.
46
+ // is one of the sandbox's forward slots (portSlotsFromToken in ./tunnel-ids), not the port number itself:
47
+ // slots keep the intentic-provided path's minted routes bounded and warm while dev servers churn ephemeral
48
+ // ports. The slot labels are salted with the connect token rather than being the letters a…h, so a forwarded
49
+ // port's hostname is not derivable from the (public) sandbox id alone — see tunnel-ids for why that matters.
48
50
  //
49
51
  // A *label* is the first-DNS-label prefix before `-<sandboxId>` (`preview-<panel>` / `port-<slot>`) — the unit
50
52
  // the platform's /sandbox/preview-route mints, so one endpoint serves both schemes.
51
53
  export const previewLabel = (panel: string): string => `preview-${panel}`;
52
54
  export const portLabel = (slot: string): string => `port-${slot}`;
53
55
 
54
- // The fixed per-sandbox forward slots. Eight is deliberate: enough for a monorepo's worth of concurrent dev
55
- // servers, and the hard cap on preview DNS records a sandbox can ever cost the shared intentic zone.
56
- export const PORT_SLOTS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
57
-
58
56
  // The hostname a label resolves to — what the platform's /sandbox/preview-route mints from the label alone.
59
57
  export const labelHostname = (label: string, id: string, zone: string): string => `${label}-${id}.${zone}`;
60
58
  export const previewHostname = (panel: string, id: string, zone: string): string => labelHostname(previewLabel(panel), id, zone);
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
 
@@ -414,7 +426,7 @@ export type UsageWindow = z.infer<typeof UsageWindowSchema>;
414
426
  // An account's headroom: EVERY window the provider reports, read together, plus when the reading was taken.
415
427
  // All of them, not the binding one, because "which pool is binding" changes between turns and a reader
416
428
  // comparing accounts needs the same pools on every row. How the reading is TAKEN is per provider and stops at
417
- // the daemon's readers: Claude's rides the turn's own stream, ChatGPT's and Google's are pulled through
429
+ // the daemon's readers: Claude's rides the turn's own stream, ChatGPT's, Google's and Kimi's are pulled through
418
430
  // CLIProxyAPI's credential-scoped management call. All of them are control requests, so none costs tokens.
419
431
  //
420
432
  // Within one window utilization only climbs, so an un-reset window stays a valid FLOOR however old it is; past
@@ -426,13 +438,45 @@ export const AccountUsageSchema = z.object({
426
438
  });
427
439
  export type AccountUsage = z.infer<typeof AccountUsageSchema>;
428
440
 
441
+ /* THE LAST TIME A PROVIDER ACTUALLY REFUSED A TURN — the other half of "can I run on this", and the half no
442
+ * meter can supply.
443
+ *
444
+ * A snapshot above is POLLED and therefore always a floor: read at turn end (Claude) or on a five-minute sweep
445
+ * (the routed subscriptions), and account-wide, so every other client on the plan spends the same pools without
446
+ * this sandbox hearing about it. A refusal is the opposite kind of fact — observed, exact, and timestamped by
447
+ * the only event that proves the plan said no. Between them they answer a question neither can alone: a green
448
+ * meter beside "refused a turn 4 minutes ago" means the reading is stale, not that the account has room.
449
+ *
450
+ * Keyed by PROVIDER rather than by account, because that is the resolution the daemon honestly has. A native
451
+ * Claude turn knows which account served it and names it; a routed turn does not — CLIProxyAPI picks the auth
452
+ * file itself and only refuses once every credential it holds is cooling down, which makes the refusal a fact
453
+ * about the provider in the first place.
454
+ *
455
+ * `kind` is read off what the provider SAID, not off the frame code the harness filed it under, because those
456
+ * two disagree: Kimi answers a spent plan with `403 You've reached your usage limit for this billing cycle`,
457
+ * which the CLI prints under "Failed to authenticate" and the stream codes as a refused credential. Sending
458
+ * someone to reconnect a perfectly good account is the cost of believing the code over the sentence. */
459
+ export const ProviderRefusalSchema = z.object({
460
+ // Epoch MS, matching AccountUsage.measuredAt — the two are read side by side.
461
+ at: z.number(),
462
+ kind: z.enum(["limit", "auth"]),
463
+ // The provider's own sentence, verbatim. It is the only part that says WHICH pool or WHICH credential.
464
+ message: z.string(),
465
+ // The account that was serving, when the daemon knows it (native turns only — see above).
466
+ account: z.string().optional(),
467
+ });
468
+ export type ProviderRefusal = z.infer<typeof ProviderRefusalSchema>;
469
+
470
+ export const ProviderRefusalsSchema = z.object({ refusals: z.record(z.string(), ProviderRefusalSchema) });
471
+ export type ProviderRefusals = z.infer<typeof ProviderRefusalsSchema>;
472
+
429
473
  // One connected subscription in the translator. `name` is CLIProxyAPI's auth-file name — the stable store key a
430
474
  // disconnect addresses — and `label` the sign-in identity it reported (the account email, else the file name).
431
475
  export const TranslatorAccountSchema = z.object({
432
476
  name: z.string(),
433
477
  label: z.string(),
434
478
  // The same headroom an OauthAccount carries, on the same field, for the same reason: the account rows are
435
- // one list to the reader. Optional because a provider whose quota this sandbox cannot read (Grok, Kimi) —
479
+ // one list to the reader. Optional because a provider whose quota this sandbox cannot read (Grok) —
436
480
  // or one that did not answer — must still render as the connected account it is, with a dot instead of a
437
481
  // ring.
438
482
  usage: AccountUsageSchema.optional(),
@@ -552,6 +596,7 @@ export const OauthAccountSchema = z.object({
552
596
  });
553
597
  export type OauthAccount = z.infer<typeof OauthAccountSchema>;
554
598
  export const OauthAccountListSchema = z.object({ accounts: z.array(OauthAccountSchema) });
599
+ export type OauthAccountList = z.infer<typeof OauthAccountListSchema>;
555
600
  // Address one account of a provider (disconnect, and the turn's `account`).
556
601
  export const AccountIdSchema = z.object({ id: z.string().min(1) });
557
602
  // Rename one account of a provider whose credential the sandbox owns (Claude, Kimi). Blank ⇒ the daemon falls
@@ -631,6 +676,7 @@ export const SessionSummarySchema = z.object({
631
676
  // own heading is noise, not evidence. See AgentMatchSchema for the same field on the fleet's side.
632
677
  snippet: z.string().optional(),
633
678
  });
679
+ export type SessionSummary = z.infer<typeof SessionSummarySchema>;
634
680
  export const SessionsListSchema = z.object({ sessions: z.array(SessionSummarySchema) });
635
681
 
636
682
  // ---- settings: per-sandbox agent settings (.intentic/settings.json) ----
@@ -1222,24 +1268,37 @@ export type FileDiff = z.infer<typeof FileDiffSchema>;
1222
1268
 
1223
1269
  // ---- workspace tree + files ----
1224
1270
 
1225
- // One node of the full /work filesystem tree the agent sees (untracked + generated files included), distinct
1226
- // from the git-tracked listing. `path` is root-relative with forward slashes so it feeds straight back to the
1227
- // file route. Recursive via zod's getter form (so the type is inferred, not hand-annotated).
1228
- 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({
1229
1293
  name: z.string(),
1230
1294
  path: z.string(),
1231
1295
  type: z.enum(["file", "dir"]),
1232
1296
  size: z.number().optional(),
1233
- // Ignored-by-tooling (node_modules, .git, .gitignore'd paths, browser profiles): the client grays the row.
1234
1297
  ignored: z.boolean().optional(),
1235
- // A DIR without `children` was listed but not descended into — because it's ignored, or because the walk's
1236
- // breadth-first budget stopped above it. Either way the client lazy-loads it via /workspace/children on
1237
- // expand, so "not loaded yet" and "empty directory" (`children: []`) stay distinguishable.
1238
1298
  get children() {
1239
1299
  return z.array(WorkspaceTreeEntrySchema).optional();
1240
1300
  },
1241
1301
  });
1242
- export type WorkspaceTreeEntry = z.infer<typeof WorkspaceTreeEntrySchema>;
1243
1302
  export const WorkspaceTreeSchema = z.object({
1244
1303
  root: z.string(),
1245
1304
  tree: z.array(WorkspaceTreeEntrySchema),
@@ -1300,14 +1359,20 @@ export type WorkspaceClassification = z.infer<typeof WorkspaceClassificationSche
1300
1359
  // The workspace-search wire shape — shared by the daemon's /workspace/search route and the web client.
1301
1360
  // (Implementation detail, not part of the contract: the daemon backs this route with a resident in-process iq
1302
1361
  // engine; the engine is interchangeable behind this shape.) Groups are relevance-ranked (best first, never path
1303
- // order); each hit carries the match-reason tags the fused engines contributed. `start`/`end` are char offsets
1304
- // 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.
1305
1364
  export const WorkspaceSearchQuerySchema = z.object({
1306
1365
  query: z.string().min(2).max(512),
1307
1366
  // Search verbs only — anchor/git verbs (outline, context, log, who, …) are CLI-only surface. Natural language
1308
1367
  // has no verb of its own: `q` classifies the query and answers it semantically when the words call for it.
1309
1368
  mode: z.enum(["q", "find", "files", "def", "refs", "sym", "ast"]).optional(),
1310
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(),
1311
1376
  limit: z.coerce.number().int().positive().optional(),
1312
1377
  after: z.string().optional(),
1313
1378
  });
@@ -1316,11 +1381,14 @@ export const WorkspaceSearchTagSchema = z.object({
1316
1381
  score: z.number().optional(),
1317
1382
  });
1318
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>;
1319
1386
  export const WorkspaceSearchHitSchema = z.object({
1320
1387
  line: z.number(),
1321
1388
  text: z.string(),
1322
- start: z.number().optional(),
1323
- 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),
1324
1392
  tags: z.array(WorkspaceSearchTagSchema),
1325
1393
  // Enclosing symbol ("createWidget (fn)") — parent-document context so the reader often needs no follow-up.
1326
1394
  context: z.string().optional(),
@@ -1342,12 +1410,19 @@ export type WorkspaceSearchFreshness = z.infer<typeof WorkspaceSearchFreshnessSc
1342
1410
  export const WorkspaceSearchResultSchema = z.object({
1343
1411
  mode: z.string(),
1344
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(),
1345
1416
  shown: z.number(),
1346
1417
  groups: z.array(WorkspaceSearchGroupSchema),
1347
1418
  freshness: WorkspaceSearchFreshnessSchema,
1348
1419
  truncated: z.boolean(),
1349
1420
  cursor: z.string().optional(),
1350
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(),
1351
1426
  // Code-graph neighbors of the top hits (definition anchors + the strongest caller of each).
1352
1427
  related: z.array(z.string()).optional(),
1353
1428
  // Ranked `path:line` anchors that placed but were NOT shown, best first — the answer often sits at rank 5–13,
@@ -1961,6 +2036,10 @@ export const ExtensionSummarySchema = z.object({
1961
2036
  // Image-baked first-party extension (no git checkout, not removable) vs a git-installed capability — the
1962
2037
  // web hides the uninstall affordance for baked ones.
1963
2038
  builtin: z.boolean(),
2039
+ // The owner's switch (.intentic/extension-enablement.json). A disabled extension is still listed — that's
2040
+ // what makes it switchable back on — but the daemon wires none of its contributions up and the web host
2041
+ // doesn't activate it.
2042
+ enabled: z.boolean(),
1964
2043
  });
1965
2044
  export type ExtensionSummary = z.infer<typeof ExtensionSummarySchema>;
1966
2045
  export const ExtensionsListSchema = z.object({ extensions: z.array(ExtensionSummarySchema) });
@@ -1977,6 +2056,9 @@ export const ExtensionSettingsInputSchema = z.object({
1977
2056
  id: z.string(),
1978
2057
  settings: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
1979
2058
  });
2059
+ // Flip one extension on or off. Persisted by publisher.name (like settings), so the choice outlives the
2060
+ // checkout; the daemon's immediate half of the flip — declared processes — converges in the same handler.
2061
+ export const ExtensionEnabledInputSchema = z.object({ id: z.string(), enabled: z.boolean() });
1980
2062
  // One declared background process (contributes.processes) — status/start/stop, addressed by the capability
1981
2063
  // entry id + the manifest's process name. Undeclared names are NOT_FOUND, the manifest-honesty rule again.
1982
2064
  export const ExtensionProcessParamSchema = z.object({ id: z.string(), name: z.string() });
@@ -2064,12 +2146,130 @@ export const TriggerSchema = z.discriminatedUnion("kind", [
2064
2146
  ]);
2065
2147
  export type Trigger = z.infer<typeof TriggerSchema>;
2066
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
+
2067
2261
  export const AutomationSchema = z.object({
2068
2262
  id: entryId,
2069
2263
  trigger: TriggerSchema,
2070
2264
  // Shell command run in the workspace root before waking; exit 0 ⇒ wake, non-zero ⇒ the run is "skipped".
2071
2265
  guard: z.string().min(1).optional(),
2072
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(),
2073
2273
  // Which provider adapter serves the wake; absent ⇒ claude. Same dispatch as a chat turn (AgentTurnSchema.agent).
2074
2274
  agent: AgentProviderSchema.optional(),
2075
2275
  // Which harness (agentic loop) runs the wake; absent ⇒ native. Same semantics as AgentTurnSchema.harness.
@@ -2427,10 +2627,9 @@ export type PortForwardResult = z.infer<typeof PortForwardResultSchema>;
2427
2627
  // it lists from /system/browsers with the pages it has open (BrowserSessionSchema below).
2428
2628
  //
2429
2629
  // `activityAt` (epoch ms of the session's last output) and `exitCode` (the LAST window's exit status, absent
2430
- // while that pane still lives) are what let a finished session be READ rather than merely listed: the panel's
2431
- // Recent-terminals popover orders by the one and reports the other ("exit 1 · 1h ago"), and the daemon's
2432
- // retention sweep ages sessions out by the same clock. 0 is "tmux didn't say" — treated as unknown by both,
2433
- // never as 1970.
2630
+ // while that pane still lives) describe a session beyond "it exists": the panel's work popover orders its live
2631
+ // rows by the one and dates them off it, and the daemon's retention sweep ages sessions out by the same clock.
2632
+ // 0 is "tmux didn't say" — treated as unknown by both, never as 1970.
2434
2633
  export const TerminalSessionSchema = z.object({
2435
2634
  name: z.string(),
2436
2635
  label: z.string().optional(),
@@ -2626,6 +2825,7 @@ export const InfoSchema = z.object({
2626
2825
  latest: z.string().optional(),
2627
2826
  updateAvailable: z.boolean().optional(),
2628
2827
  });
2828
+ export type Info = z.infer<typeof InfoSchema>;
2629
2829
 
2630
2830
  // A daemon-minted session (system.session): the steady-state browser credential, exchanged for a verified
2631
2831
  // Google ID token so Google UI is a sign-in moment instead of an hourly renewal. `expiresAt` is epoch ms —
@@ -2932,3 +3132,211 @@ export const PushConfigQuerySchema = z.object({ endpoint: z.string().url().optio
2932
3132
  // the question being asked. A count separates "your OS swallowed it" from "nothing was sent at all".
2933
3133
  export const PushTestSchema = z.object({ delivered: z.number().int().nonnegative() });
2934
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;