@intentic/sandbox-contract 1.230.3 → 1.232.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 (72) hide show
  1. package/README.md +11 -0
  2. package/dist/contracts/agent.contract.d.ts +53 -6
  3. package/dist/contracts/agent.contract.d.ts.map +1 -1
  4. package/dist/contracts/agent.contract.js +10 -1
  5. package/dist/contracts/agent.contract.js.map +1 -1
  6. package/dist/contracts/agents.contract.d.ts +6 -1
  7. package/dist/contracts/agents.contract.d.ts.map +1 -1
  8. package/dist/contracts/capabilities.contract.d.ts +293 -0
  9. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  10. package/dist/contracts/capabilities.contract.js +10 -1
  11. package/dist/contracts/capabilities.contract.js.map +1 -1
  12. package/dist/contracts/extensions.contract.d.ts +15 -0
  13. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  14. package/dist/contracts/git.contract.d.ts +12 -2
  15. package/dist/contracts/git.contract.d.ts.map +1 -1
  16. package/dist/contracts/history.contract.d.ts +6 -1
  17. package/dist/contracts/history.contract.d.ts.map +1 -1
  18. package/dist/contracts/host.contract.d.ts +4 -0
  19. package/dist/contracts/host.contract.d.ts.map +1 -1
  20. package/dist/contracts/panels.contract.d.ts +1 -0
  21. package/dist/contracts/panels.contract.d.ts.map +1 -1
  22. package/dist/contracts/runner.contract.d.ts +876 -0
  23. package/dist/contracts/runner.contract.d.ts.map +1 -0
  24. package/dist/contracts/runner.contract.js +17 -0
  25. package/dist/contracts/runner.contract.js.map +1 -0
  26. package/dist/contracts/system.contract.d.ts +6 -3
  27. package/dist/contracts/system.contract.d.ts.map +1 -1
  28. package/dist/definition.d.ts +961 -0
  29. package/dist/definition.d.ts.map +1 -0
  30. package/dist/definition.js +71 -0
  31. package/dist/definition.js.map +1 -0
  32. package/dist/documents.d.ts +7 -0
  33. package/dist/documents.d.ts.map +1 -0
  34. package/dist/documents.js +27 -0
  35. package/dist/documents.js.map +1 -0
  36. package/dist/events.d.ts +66 -6
  37. package/dist/events.d.ts.map +1 -1
  38. package/dist/events.js +17 -1
  39. package/dist/events.js.map +1 -1
  40. package/dist/history-state.d.ts.map +1 -1
  41. package/dist/history-state.js +3 -0
  42. package/dist/history-state.js.map +1 -1
  43. package/dist/index.d.ts +396 -13
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +4 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/runner-protocol.d.ts +134 -0
  48. package/dist/runner-protocol.d.ts.map +1 -0
  49. package/dist/runner-protocol.js +95 -0
  50. package/dist/runner-protocol.js.map +1 -0
  51. package/dist/schemas.d.ts +194 -32
  52. package/dist/schemas.d.ts.map +1 -1
  53. package/dist/schemas.js +80 -25
  54. package/dist/schemas.js.map +1 -1
  55. package/dist/workspace-state.d.ts +9 -0
  56. package/dist/workspace-state.d.ts.map +1 -1
  57. package/dist/workspace-state.js +2 -0
  58. package/dist/workspace-state.js.map +1 -1
  59. package/package.json +6 -6
  60. package/src/contracts/agent.contract.ts +19 -0
  61. package/src/contracts/capabilities.contract.ts +16 -0
  62. package/src/contracts/runner.contract.ts +49 -0
  63. package/src/definition.ts +171 -0
  64. package/src/documents.test.ts +66 -0
  65. package/src/documents.ts +71 -0
  66. package/src/events.ts +88 -2
  67. package/src/history-state.ts +7 -0
  68. package/src/index.ts +5 -0
  69. package/src/runner-protocol.ts +209 -0
  70. package/src/schemas.ts +225 -65
  71. package/src/workspace-state.test.ts +4 -0
  72. package/src/workspace-state.ts +19 -0
package/src/schemas.ts CHANGED
@@ -3,6 +3,7 @@ import { ExtensionManifestSchema } from "@intentic/extension-manifest";
3
3
  import { RegistryEntrySchema } from "@intentic/registry";
4
4
  import { z } from "zod";
5
5
  import { OutputFieldsSchema } from "./output-fields.js";
6
+ import { AgentPlacementSchema } from "./runner-protocol.js";
6
7
 
7
8
  // All request/response wire schemas for the sandbox daemon. Inputs that carry a `{param}` in their route path
8
9
  // (repo / id / name) merge the path param into the same flat object, oRPC fills the path placeholder from the
@@ -253,6 +254,13 @@ export const AgentTurnSchema = z
253
254
  .describe(
254
255
  "Work in this conversation's own private copy of the repos rather than the shared tree, so several agents can work at once. Needs a conversation id.",
255
256
  ),
257
+ /* WHERE the conversation executes, decided like `isolated` directly above: the request's choice on the
258
+ * first turn, the registry entry's on every turn after. `runner` implies isolation (the branch is what
259
+ * moves between machines) and needs a conversation id for the same reason `isolated` does. Absent =
260
+ * local. Design: docs/remote-runners-plan.md; refused until runners ship. */
261
+ placement: AgentPlacementSchema.optional().describe(
262
+ "Where this conversation runs: this sandbox (leave it out), or a paired runner by id. Decided on the first turn; later turns follow the conversation.",
263
+ ),
256
264
  /* Pin a NEW isolated conversation's worktree composition to these repository commits. Daemon-owned:
257
265
  * ordinary chats omit it and keep rebasing onto the current workspace; a workflow supplies the one
258
266
  * snapshot all of its candidates must share. Repeated iterations carry it too, which suppresses the
@@ -1929,6 +1937,23 @@ export const SteerSchema = z
1929
1937
  // /agent fetch (which sends no cancel frame).
1930
1938
  export const StopTurnSchema = z.object({ conversationId: z.string().min(1).describe("Which conversation's running turn to cancel.") });
1931
1939
 
1940
+ /* RUN THE HELD TURN AGAIN, and it carries a conversation id and NOTHING else, which is the entire point of it
1941
+ * existing as its own route rather than as a flag on a turn.
1942
+ *
1943
+ * A spent allowance leaves a turn stranded that the daemon still holds in full: the prompt, the attachments, the
1944
+ * model, the effort, the mode, the worktree, the session that holds whatever it managed to do. Every one of
1945
+ * those is on the turn the daemon already has, and a client that re-derived them from its own transcript would
1946
+ * be re-deriving them from the STRIPPED copy it renders (no preamble notes, no attachment note, no model), which
1947
+ * is how a re-send comes to run a different turn from the one it claims to repeat.
1948
+ *
1949
+ * So the caller says only WHICH conversation, and the daemon re-runs the turn it kept. What comes back is an
1950
+ * ordinary StartedTurn, and the caller then attaches to it exactly as it would to a turn somebody else started
1951
+ * (the resume note on the prompt is what tells an attaching window to reuse the bubble that is already there
1952
+ * instead of drawing the same message twice). */
1953
+ export const ResumeTurnSchema = z.object({
1954
+ conversationId: z.string().min(1).describe("Which conversation's held turn to run again."),
1955
+ });
1956
+
1932
1957
  // ---- claude rate-limit gate ----
1933
1958
  // The GATE signal: whether the provider is letting turns through right now, and, when it is refusing, which
1934
1959
  // window is binding and when it lifts. This is the SDK's rate_limit_event, mapped one-to-one, and it is only
@@ -2778,7 +2803,13 @@ export const SandboxSettingsSchema = z.object({
2778
2803
  * `<provider>.<type>` ("discord.message.send") with `<provider>.*` as the per-provider wildcard; exact key
2779
2804
  * wins. An action with no rule is allowed, the empty default wires no hook at all, so an unconfigured
2780
2805
  * workspace pays nothing. "hold" cannot park a running turn (nobody may be there to answer); it refuses the
2781
- * live call and points the agent at the drafts outbox, which IS the held form of a send. */
2806
+ * live call and points the agent at the drafts outbox, which IS the held form of a send.
2807
+ *
2808
+ * The CHILD-AGENT surface reads the same book: `agents.spawn` covers starting, steering and answering
2809
+ * child agents on every provider, `agents.spawn.<provider>` singles one out (the specific key wins), and
2810
+ * the daemon's own taint floor holds a spawn from a turn that has taken in outside content unless the
2811
+ * owner wrote an explicit allow (guard/actions.ts childSpawn). "hold" refuses with the owner named, the
2812
+ * same translation a send gets. */
2782
2813
  actionRules: z
2783
2814
  .record(z.string(), AdmissionRuleSchema)
2784
2815
  .default({})
@@ -3812,13 +3843,43 @@ export const SnapshotFileDiffQuerySchema = z.object({
3812
3843
  scope: z.string().min(1).describe("Which part of the workspace the path belongs to."),
3813
3844
  path: z.string().min(1).describe("The file, relative to that scope."),
3814
3845
  });
3846
+ /* WHAT A FILE TOO BIG TO SEND WHOLE ANSWERS WITH INSTEAD, and why that is not simply "no".
3847
+ *
3848
+ * Both whole sides of a half-megabyte file are a megabyte of JSON per click, so above the cap they are not
3849
+ * sent, which used to be the end of it: the response said "too large" and every review surface printed one
3850
+ * sentence over an empty pane. That is the wrong trade, because the thing a reader wants out of a big file is
3851
+ * almost never the file: it is the handful of lines that MOVED, and those are small however big the file is.
3852
+ *
3853
+ * So the daemon diffs it and sends the CHANGED REGIONS as a unified patch, at the same three lines of context
3854
+ * a collapsed region keeps elsewhere. A 40 KB patch stands in for a 60 MB pair, and the reader gets the actual
3855
+ * review rather than a refusal. `patch` carries the `@@` sections only, the file headers git prints above them
3856
+ * name rev-specs no one can apply anyway.
3857
+ *
3858
+ * An added or deleted file has no counterpart to diff against, so its patch IS the file, one region of pure
3859
+ * +/− lines. That is still the right answer: cut to the budget, it is the head of the file, which is the peek
3860
+ * the reader came for.
3861
+ *
3862
+ * `patch` is absent only when there was nothing to make one from: a change too large even to render as a
3863
+ * patch, or a git that refused. The sizes are still there, so a surface can at least say how big the thing it
3864
+ * is not showing is. */
3865
+ export const PartialFileDiffSchema = z.object({
3866
+ beforeBytes: z.number().int().nonnegative().optional().describe("How big the before side is, in bytes. Absent when the file did not exist yet."),
3867
+ afterBytes: z.number().int().nonnegative().optional().describe("How big the after side is, in bytes. Absent when the file was deleted."),
3868
+ patch: z
3869
+ .string()
3870
+ .optional()
3871
+ .describe("The changed regions as unified-diff hunks (`@@` sections only). Absent when the change was too large to render even as a patch."),
3872
+ more: z.boolean().optional().describe("There were more changed regions than fit; the patch stops at a region boundary."),
3873
+ });
3874
+ export type PartialFileDiff = z.infer<typeof PartialFileDiffSchema>;
3875
+
3815
3876
  // Both sides of a file diff, a snapshot vs its parent, or a working tree vs HEAD; an absent side means the
3816
- // file was added/deleted. Binary or oversized content is flagged instead of shipped.
3877
+ // file was added/deleted. Binary content is flagged instead of shipped; oversized content arrives as `partial`.
3817
3878
  export const FileDiffSchema = z.object({
3818
- before: z.string().optional().describe("The whole file as it was. Absent when it did not exist yet."),
3819
- after: z.string().optional().describe("The whole file as it is now. Absent when it was deleted."),
3879
+ before: z.string().optional().describe("The whole file as it was. Absent when it did not exist yet, or when `partial` is set."),
3880
+ after: z.string().optional().describe("The whole file as it is now. Absent when it was deleted, or when `partial` is set."),
3820
3881
  binary: z.boolean().optional().describe("The file is not text, so neither side is sent."),
3821
- truncated: z.boolean().optional().describe("The file was too large to send whole, so what you have is the start of it."),
3882
+ partial: PartialFileDiffSchema.optional().describe("Set when the file was too large to send whole: what is sent instead of the two sides."),
3822
3883
  });
3823
3884
  export type FileDiff = z.infer<typeof FileDiffSchema>;
3824
3885
 
@@ -5617,6 +5678,26 @@ export const CapabilityOtpSchema = z.object({
5617
5678
  .describe("How long it lasts. Its expiring is what makes handing one to an agent safe, since the seed behind it is never revealed."),
5618
5679
  });
5619
5680
 
5681
+ /* POST /capabilities/probe response: did these settings actually reach the thing, asked BEFORE they are saved.
5682
+ *
5683
+ * The answer is a sentence rather than a status code because the reader is standing in front of a form: what
5684
+ * they need is either the service's own confirmation ("Reached GitHub, authenticated as ada") or the exact
5685
+ * refusal ("GitHub answered 401: the token is not valid"), in the place where the box they would fix still is.
5686
+ * That is also the whole point of doing it here: every one of these failures is otherwise discovered after the
5687
+ * add, on a card that says "not connected" with nothing about which of six answers was wrong.
5688
+ *
5689
+ * `ok: false` is a REPORTED failure, not a transport error: the probe ran and the service said no. A card whose
5690
+ * settings cannot be checked from here at all answers `checked: false`, which is a different thing from a
5691
+ * failure and must never be drawn as one. */
5692
+ export const CapabilityProbeSchema = z.object({
5693
+ checked: z.boolean().describe("Whether this connection can be tested from here at all. False is not a failure: it is 'no test exists'."),
5694
+ ok: z.boolean().describe("Whether the service answered as itself."),
5695
+ message: z
5696
+ .string()
5697
+ .describe("What happened, in the words a person standing in front of the form needs: the service's own answer, or its refusal."),
5698
+ });
5699
+ export type CapabilityProbe = z.infer<typeof CapabilityProbeSchema>;
5700
+
5620
5701
  // ---- hosts: the user's own connected computers (the `host` capability's live half) ----
5621
5702
  // The manifest says which machines the user INTENDS to have connected; this says which are actually holding a
5622
5703
  // socket right now. Nothing here is remembered across a daemon restart except the enrollment itself: a machine
@@ -7625,6 +7706,9 @@ export const PanelSummarySchema = z.object({
7625
7706
  servers: z
7626
7707
  .array(
7627
7708
  z.object({
7709
+ // The port itself, not just the URL it appears in: forwarding one is how a repo answering on
7710
+ // several ports becomes previewable at all, and that call takes a number.
7711
+ port: z.number().describe("The port it is listening on, which is what forwarding it takes."),
7628
7712
  url: z.string().describe("Where it answers, with the right scheme: a server on its own certificate is served over https."),
7629
7713
  dir: z
7630
7714
  .string()
@@ -7641,8 +7725,15 @@ export const PanelSummarySchema = z.object({
7641
7725
  }),
7642
7726
  )
7643
7727
  .describe("Every server this repository is really serving, found by looking at what is listening. Empty when nothing answers."),
7644
- // https://preview-<repo>-<sandboxId>.<zone>; absent when the sandbox has no zone or connect token (loopback/tests).
7645
- previewUrl: z.string().optional().describe("Where to open it from outside. Absent on a sandbox with no outside address."),
7728
+ /* https://preview-<repo>-<sandboxId>.<zone>, and ONLY where that address actually serves this repo: absent
7729
+ * on a sandbox with no zone or connect token (loopback/tests), and absent whenever the preview proxy has
7730
+ * nothing to route it to — nothing running, still starting, or (the ordinary monorepo) several dev servers
7731
+ * on ports of their own, none of which one hostname can stand for. Present ⇒ safe to open or frame, which
7732
+ * is what stops a surface from showing a 502 as if it were the app. */
7733
+ previewUrl: z
7734
+ .string()
7735
+ .optional()
7736
+ .describe("Where to open it from outside, present only while that address really serves it. Absent on a sandbox with no outside address."),
7646
7737
  // The workspace role this repo dir occupies (the three fixed dirs); absent for extra clones.
7647
7738
  role: z
7648
7739
  .enum(["intent", "desired-state", "app"])
@@ -7816,15 +7907,41 @@ export type MachineSandbox = z.infer<typeof MachineSandboxSchema>;
7816
7907
  *
7817
7908
  * The machine enforces which of them it will do: `sandboxes` covers everything but removal, which takes its own
7818
7909
  * switch, and a refusal comes back as the machine's own sentence naming the control to flip. */
7819
- export const MachineSandboxOpSchema = z.enum(["start", "stop", "restart", "prepare", "update", "rebuild", "rollback", "remove", "logs"]);
7910
+ /* `runner-up` / `runner-remove` are the same door for a container that belongs to THIS SANDBOX rather than to
7911
+ * a person: a runner (runners/, docs/remote-runners-plan.md at the workspace root). They ride here because to
7912
+ * the machine they are the same act it already does, run and remove a sandbox container, and to the person
7913
+ * clicking they are the same row of buttons. Both take the `sandboxes` switch and neither takes the removal
7914
+ * one: a runner holds no workspace of its own, only a mirror of the parent's git, so removing it destroys
7915
+ * nothing the parent does not still have. */
7916
+ export const MachineSandboxOpSchema = z.enum([
7917
+ "start",
7918
+ "stop",
7919
+ "restart",
7920
+ "prepare",
7921
+ "update",
7922
+ "rebuild",
7923
+ "rollback",
7924
+ "remove",
7925
+ "logs",
7926
+ "runner-up",
7927
+ "runner-remove",
7928
+ ]);
7820
7929
  export type MachineSandboxOp = z.infer<typeof MachineSandboxOpSchema>;
7821
7930
 
7822
7931
  export const MachineSandboxFlowSchema = z.object({
7823
7932
  op: MachineSandboxOpSchema,
7933
+ // Which sandbox, or, for the two runner ops, which RUNNER: the name it is known by at both ends, the
7934
+ // parent's `/system/runners` list and the machine's `ic runner list`.
7824
7935
  slug: z.string().min(1),
7825
7936
  // The approved overlay's sha256, required by `rebuild` and meaningless to the rest. It is the trust anchor:
7826
7937
  // only content that still hashes to what the owner reviewed is ever built.
7827
7938
  hash: z.string().optional(),
7939
+ /* `runner-up` only, and both are filled in by the DAEMON, never by the caller: where the runner dials
7940
+ * (this sandbox's public URL) and the single-use pairing it redeems there. The browser asks for a runner
7941
+ * on a machine; it never holds the credential that makes one, which is what keeps a pairing out of every
7942
+ * surface between here and that machine. */
7943
+ parentUrl: z.string().optional(),
7944
+ pair: z.string().optional().meta({ secret: true }),
7828
7945
  });
7829
7946
  export type MachineSandboxFlow = z.infer<typeof MachineSandboxFlowSchema>;
7830
7947
 
@@ -8323,28 +8440,28 @@ export const BrowserNameParamSchema = z.object({ name: z.string().describe("Whic
8323
8440
  * (another agent, working, that you did not start):
8324
8441
  * • `subagent`, the SDK's Agent/Task tool. The daemon learns of it from the SubagentStart/SubagentStop hooks
8325
8442
  * and the task_* stream messages, joined on `toolUseId`.
8326
- * • `codex` / `grok`, a CLI the agent drove from its own Bash (agent/delegation.ts). Detected in the Bash
8327
- * PreToolUse hook, bound to its thread/session id from the command's output.
8328
- *
8329
- * `id` IS THE SPAWNING TOOL CALL'S id, the Agent card's, or the Bash card's for a delegation. It is the one key
8330
- * every source already carries (the SDK's subagent meta, its task_* messages, and the `parentToolUseId` the
8331
- * client nests inner frames under), so nothing has to be correlated: a card links to its subagent with the id it
8332
- * already has, and the subagent points back at the card the same way. The ids the transcripts are actually READ
8333
- * with, the SDK's agent id, a Codex thread, an OpenCode session, stay daemon-side, because no surface asks a
8334
- * question they answer.
8335
- *
8336
- * WHAT A KIND CHANGES, and it is only ever the live view: a subagent has no process of its own to look at, so
8337
- * watching it means reading its transcript. A delegation runs in a tmux window, so it has both, `terminal`
8338
- * names it, and the card keeps its existing "Watch in terminal" beside the transcript door. */
8339
- export const SubagentKindSchema = z.enum(["subagent", "codex", "grok"]);
8443
+ * • `spawned`, a full agent the turn started through the daemon's own spawn door (children/children.ts), on
8444
+ * ANY connected provider, Cursor's Composer, Codex, Gemini, another Claude. The daemon runs the child
8445
+ * itself, so its whole life is reported by direct calls rather than reconstructed from hooks or stdout.
8446
+ *
8447
+ * `id` IS THE SPAWNING TOOL CALL'S id for an SDK child (the one key its meta file, its task messages and the
8448
+ * client's `parentToolUseId` nesting all carry), and the child's own conversation id for a `spawned` one (the
8449
+ * spawn door returns it, so both sides hold it). A card links to its subagent with the id it has, and the
8450
+ * subagent points back at the card the same way. The ids the transcripts are actually READ with, the SDK's
8451
+ * agent id, stay daemon-side, because no surface asks a question they answer.
8452
+ *
8453
+ * WHAT A KIND CHANGES, and it is only ever the live view: an SDK subagent has no process of its own to look
8454
+ * at, so watching it means reading its transcript; a spawned child is a conversation of its own, so its live
8455
+ * view is that conversation's stream. */
8456
+ export const SubagentKindSchema = z.enum(["subagent", "spawned"]);
8340
8457
  export type SubagentKind = z.infer<typeof SubagentKindSchema>;
8341
8458
 
8342
8459
  // running/pending/blocked are live; the rest are terminal. Deliberately the SDK's own task vocabulary
8343
8460
  // (SDKTaskUpdatedMessage.patch.status) rather than AgentStatus: this is not a fleet card's lifecycle (no
8344
8461
  // draft/landed/conflict), and mapping the two would invent states neither side reports. `blocked` is the one
8345
- // addition the SDK never says: it comes from a delegated CLI's own signals (a Codex PermissionRequest hook, an
8346
- // OpenCode permission ask, agent/delegation-signals.ts), and it exists because "the child needs an answer" is
8347
- // the one live state a parent or an operator acts on differently from "the child is working".
8462
+ // addition the SDK never says: a spawned child's own question/permission/plan card raises it
8463
+ // (children/children.ts), and it exists because "the child needs an answer" is the one live state a parent or
8464
+ // an operator acts on differently from "the child is working".
8348
8465
  export const SubagentStatusSchema = z.enum(["pending", "running", "blocked", "completed", "failed", "killed", "paused"]);
8349
8466
  export type SubagentStatus = z.infer<typeof SubagentStatusSchema>;
8350
8467
 
@@ -8352,20 +8469,23 @@ export const SubagentSessionSchema = z.object({
8352
8469
  id: z
8353
8470
  .string()
8354
8471
  .describe(
8355
- "The id of the tool call that started it, which every side already holds, so a card links to its helper with the id it has and the helper points back the same way.",
8472
+ "The id of the tool call that started it (an SDK child) or the child's own conversation id (a spawned one); either way both sides already hold it, so a card links to its helper with the id it has and the helper points back the same way.",
8356
8473
  ),
8357
8474
  kind: SubagentKindSchema.describe(
8358
- "What sort of helper: one the runtime spawned, or a separate tool the agent drove from a shell. It changes only how you watch it.",
8475
+ "What sort of helper: one the runtime's own Task tool spawned in-process, or a full agent the daemon started for the turn. It changes only how you watch it.",
8359
8476
  ),
8360
8477
  // The conversation whose turn spawned this, what the area groups its rows by, and the way back to the chat
8361
8478
  // the card lives in.
8362
8479
  conversationId: z.string().describe("The conversation whose turn started it, and the way back to the chat it belongs to."),
8363
- // What it is and what it was asked to do: the subagent type (`Explore`, `general-purpose`) or the delegated
8364
- // provider's model, and the caller's one-line description. The area's row and the card's title read as
8365
- // `Explore · Locate claimIndexer definition`.
8480
+ // What it is and what it was asked to do: the subagent type (`Explore`, `general-purpose`) or a spawned
8481
+ // child's provider label, and the caller's one-line description. The area's row and the card's title read
8482
+ // as `Explore · Locate claimIndexer definition`.
8366
8483
  agentType: z.string().optional().describe("What kind of helper it is."),
8367
8484
  description: z.string().optional().describe("What it was asked to do, in one line."),
8368
8485
  model: z.string().optional().describe("Which model it runs on."),
8486
+ // Which provider serves a `spawned` child (its AgentProvider id), so the row can wear the right logo. An
8487
+ // SDK subagent implies its own: it runs on its parent's provider.
8488
+ provider: z.string().optional().describe("Which provider serves it, for a helper spawned across providers."),
8369
8489
  // How deep in the spawn tree (1 = spawned by the turn itself). From the SDK's meta.json; a subagent may
8370
8490
  // itself delegate, and a flat list that cannot say so reads as though the turn started all of them.
8371
8491
  spawnDepth: z
@@ -8403,14 +8523,6 @@ export const SubagentSessionSchema = z.object({
8403
8523
  .optional()
8404
8524
  .describe("Its report: what it concluded, without opening its record. The question a finished helper gets read for."),
8405
8525
  error: z.string().optional().describe("Why it failed, when it did."),
8406
- // A delegation's live view: the tmux session its command runs in. Absent for an SDK subagent, which has no
8407
- // process of its own to attach to.
8408
- terminal: z
8409
- .string()
8410
- .optional()
8411
- .describe(
8412
- "The terminal its command runs in, when there is one. Absent for a helper with no process of its own, which is watched by reading its record instead.",
8413
- ),
8414
8526
  });
8415
8527
  export type SubagentSession = z.infer<typeof SubagentSessionSchema>;
8416
8528
  export const SubagentsListSchema = z.object({
@@ -8431,6 +8543,73 @@ export const SubagentIdParamSchema = z.object({ id: z.string() });
8431
8543
  // proposal present with a hash different from custom's.
8432
8544
 
8433
8545
  const environmentFileSchema = z.object({ content: z.string(), hash: z.string() });
8546
+
8547
+ /* ---- environment DRIFT: what the live container has that the image did not put there ----
8548
+ *
8549
+ * Anything installed outside /work dies with the container, and transcript mining showed the same tools being
8550
+ * reinstalled session after session (cargo-xwin six times, a Windows rustup target eight) before anyone thought
8551
+ * to bake them. Drift is the daemon OBSERVING that gap rather than trusting the model to report it: apt installs
8552
+ * read from dpkg's own log, everything else from system paths newer than the container itself. Two channels
8553
+ * because they are disjoint by construction — dpkg unpacks files with their archive mtimes, so an mtime sweep
8554
+ * cannot see apt, and nothing apt does lands under the swept prefixes' hand-installed corners. */
8555
+ export const EnvironmentDriftSchema = z.object({
8556
+ // When this container was created (PID 1's start). A snapshot whose bornAt is not the running container's
8557
+ // describes a container that no longer exists, and every reader must treat it as no drift at all.
8558
+ bornAt: z.number(),
8559
+ // When the probe ran.
8560
+ at: z.number(),
8561
+ // Debian packages installed since the container was born, from /var/log/dpkg.log.
8562
+ apt: z.array(z.string()),
8563
+ // System paths (outside /work) newer than the container, collapsed so a browser download is one entry.
8564
+ paths: z.array(z.string()),
8565
+ });
8566
+ export type EnvironmentDrift = z.infer<typeof EnvironmentDriftSchema>;
8567
+
8568
+ // How a runtime install was made, which decides whether the daemon can draft a Dockerfile step for it
8569
+ // mechanically (apt/cargo/npm/rustup-target) or only surface it for a person to route (pip belongs in a venv or
8570
+ // a Debian package, "other" is a curl|sh whose replay could embed anything).
8571
+ export const RuntimeInstallKindSchema = z.enum(["apt", "pip", "cargo", "npm", "rustup-target", "playwright", "gem", "pipx", "go", "other"]);
8572
+ export type RuntimeInstallKind = z.infer<typeof RuntimeInstallKindSchema>;
8573
+
8574
+ /* One tool's runtime-install history across sessions: the ledger entry behind the recurrence signal. Sessions
8575
+ * are the unit of recurrence — a session that retries an install five times needed it once — and the entry
8576
+ * survives container recreates (the file lives under /work), which is exactly what makes "installed again in a
8577
+ * fresh container" observable at all. */
8578
+ export const RuntimeInstallSchema = z.object({
8579
+ tool: z.string(),
8580
+ kind: RuntimeInstallKindSchema,
8581
+ // Distinct conversation ids that installed it, capped; length is the recurrence count that gates drafting.
8582
+ sessions: z.array(z.string()),
8583
+ // The most recent install commands, capped, secrets already masked to references by the harness.
8584
+ commands: z.array(z.string()),
8585
+ firstAt: z.number(),
8586
+ lastAt: z.number(),
8587
+ count: z.number(),
8588
+ // The owner rejected an auto-drafted step for this tool: never propose it again until this is cleared.
8589
+ declinedAt: z.number().optional(),
8590
+ });
8591
+ export type RuntimeInstall = z.infer<typeof RuntimeInstallSchema>;
8592
+
8593
+ export const RuntimeInstallsFileSchema = z.object({
8594
+ installs: z.array(RuntimeInstallSchema),
8595
+ // The last drift snapshot, persisted so a daemon restart does not blank the card until the next sweep.
8596
+ drift: EnvironmentDriftSchema.optional(),
8597
+ });
8598
+ export type RuntimeInstallsFile = z.infer<typeof RuntimeInstallsFileSchema>;
8599
+
8600
+ // A ledger entry as the Environment card shows it: recurrence joined with whether the install is present in the
8601
+ // LIVE container (drift-corroborated), already drafted for approval, or previously declined.
8602
+ export const EnvironmentRecurringSchema = z.object({
8603
+ tool: z.string(),
8604
+ kind: RuntimeInstallKindSchema,
8605
+ sessions: z.number(),
8606
+ lastAt: z.number(),
8607
+ live: z.boolean(),
8608
+ drafted: z.boolean().optional(),
8609
+ declined: z.boolean().optional(),
8610
+ });
8611
+ export type EnvironmentRecurring = z.infer<typeof EnvironmentRecurringSchema>;
8612
+
8434
8613
  export const EnvironmentSchema = z.object({
8435
8614
  proposal: environmentFileSchema.optional(),
8436
8615
  // The owner-approved agent-written custom section (.intentic/config/environment.custom.Dockerfile).
@@ -8440,6 +8619,10 @@ export const EnvironmentSchema = z.object({
8440
8619
  appliedHash: z.string().optional(),
8441
8620
  // config.sandbox.name, the UI derives the rebuild one-liner's slug from it.
8442
8621
  container: z.string().optional(),
8622
+ // What the live container has that the image did not put there; absent until the first sweep of this container.
8623
+ drift: EnvironmentDriftSchema.optional(),
8624
+ // Runtime installs worth the owner's attention: recurring across sessions, or present-and-doomed right now.
8625
+ recurring: z.array(EnvironmentRecurringSchema).optional(),
8443
8626
  });
8444
8627
  export type Environment = z.infer<typeof EnvironmentSchema>;
8445
8628
  export const EnvironmentApproveSchema = z.object({ hash: z.string().min(1) });
@@ -8508,33 +8691,10 @@ export type EnvironmentContents = z.infer<typeof EnvironmentContentsSchema>;
8508
8691
  * by entry in WORKSPACE_STATE_FILES / HISTORY_STATE_FILES. It cannot carry the other two, and the honest
8509
8692
  * consequence is that an import ends in a REPORT rather than a claim of equivalence, the container has no
8510
8693
  * docker socket, so only the host can rebuild the image the overlay describes.
8511
- */
8512
-
8513
- // What the bundle says about itself, written as its first tar entry so a reader learns the shape before the
8514
- // bytes. `secrets` is the owner's export-time choice; the restorer re-derives every decision from the manifests
8515
- // rather than trusting this, and uses it only to explain what is missing.
8516
- export const BundleManifestSchema = z.object({
8517
- // Bumped when the layout changes in a way an older daemon would misread. Refused rather than guessed at.
8518
- version: z.literal(1),
8519
- // Where it came from, for the report's first line. Never used to authorize anything.
8520
- sandbox: z.object({ name: z.string() }).optional(),
8521
- createdAt: z.number(),
8522
- secrets: z.boolean(),
8523
- /* The environment the target has to reproduce, carried as FACTS rather than as the composed file (which the
8524
- * target recomposes against its OWN base image on first boot). `customDockerfile` is the owner-approved
8525
- * source section; `capabilities` names what contributed the remaining fragments, so the report can list what
8526
- * to re-add when the configs themselves did not travel. */
8527
- environment: z.object({
8528
- customDockerfile: z.string().optional(),
8529
- baseImage: z.string().optional(),
8530
- approvedHash: z.string().optional(),
8531
- capabilities: z.array(z.object({ id: z.string(), kind: z.string() })),
8532
- }),
8533
- // Every path class the bundle deliberately left out, with the manifest's own note where it has one. This is
8534
- // what turns "the export skipped things" from a silence into a list the owner can act on.
8535
- excluded: z.array(z.object({ path: z.string(), portability: z.string(), note: z.string().optional() })),
8536
- });
8537
- export type BundleManifest = z.infer<typeof BundleManifestSchema>;
8694
+ *
8695
+ * The bundle's manifest (BundleManifestSchema) lives in definition.ts beside the sandbox DEFINITION it embeds:
8696
+ * a bundle is definition + state, and keeping the two schemas together is what keeps the two export doors from
8697
+ * drifting into different answers about what an environment is. */
8538
8698
 
8539
8699
  // What a restore actually did. `needsAction` is the part that matters: the environment rebuild command, the
8540
8700
  // credentials to re-enter, the logins to redo, each one a thing the target cannot do for itself.
@@ -343,6 +343,10 @@ describe(`VERSIONED_STATE_PATHS`, () => {
343
343
  // The owner's per-extension update posture (notify / agent / auto): a standing decision about
344
344
  // what may run unattended, which is exactly the kind of edit worth a line in `git log`.
345
345
  `.intentic/config/extension-update-policy.json`,
346
+ // Which commands are heavy enough to take turns, and how many may run at once. Tracked because
347
+ // raising that limit is a decision about every session sharing the box, and `git log` is the only
348
+ // thing that answers "since when have we been allowing four of these at a time".
349
+ `.intentic/config/heavy-commands.json`,
346
350
  `.intentic/config/loop-designs.json`,
347
351
  `.intentic/config/personas.json`,
348
352
  // A persona's own kit: the prompt it runs on and the skills only its turns reach. Tracked for the
@@ -221,6 +221,18 @@ const STATE_FILES = [
221
221
  },
222
222
 
223
223
  { path: ".intentic/config/settings.json", invalidates: ["settings", "manifests"], portability: "carry", versioned: true },
224
+ /* Which agent commands are heavy enough to take turns, and how many may run at once (the daemon reads it
225
+ * per Bash command: platform/heavy-commands.ts).
226
+ *
227
+ * `carry`, because the answer is a property of the WORKSPACE rather than of this machine: `pnpm test` fans
228
+ * out to the same 74 packages wherever the repo is cloned, so a fresh sandbox should arrive already knowing
229
+ * which commands to queue rather than rediscovering it by freezing once.
230
+ *
231
+ * `versioned` for the reason the config slice generally is (personas.json's entry argues it): the file
232
+ * changes at human speed, it holds no secret, and a change to it is exactly the kind a reviewer should see
233
+ * — raising the limit is a decision about everyone's sessions on that box, and `git log` is the only thing
234
+ * that answers "since when have we allowed four of these at once". */
235
+ { path: ".intentic/config/heavy-commands.json", invalidates: ["settings"], portability: "carry", versioned: true },
224
236
  // The rule table's last-fired stamps, beside the rules themselves. `derived` rather than `carry`: it is a
225
237
  // record of what happened in THIS sandbox, and carrying it to a fresh one would date every rule to work
226
238
  // that machine never did.
@@ -230,6 +242,13 @@ const STATE_FILES = [
230
242
  portability: "derived",
231
243
  note: "Stamps of when each rule last did something; the new sandbox starts its own record.",
232
244
  },
245
+ /* The runtime-install ledger: which tools sessions installed into the container at runtime, how often, and
246
+ * the last drift snapshot (environment/runtime-installs.ts). `carry` where rule-firings chose `derived`,
247
+ * because the two record different subjects: a firing is about what THIS machine did, while the ledger is
248
+ * about what this WORKSPACE's tasks keep needing — a workspace moved to a fresh sandbox will hit the same
249
+ * missing tools, and arriving with the recurrence memory is the whole reason it is kept. The drift snapshot
250
+ * inside is machine-scoped, and self-expires on the move: its bornAt can never match the new container. */
251
+ { path: ".intentic/records/runtime-installs.json", invalidates: ["environment"], portability: "carry" },
233
252
  /* Written by the AGENT's file tools (the drafts skill), read by the owner's approval inbox, the one entry
234
253
  * here whose whole point is that a change arrives from outside the browser that renders it. `authored`:
235
254
  * a draft is text somebody wrote, and "find the reddit draft about X" is an ordinary search.