@intentic/sandbox-contract 1.308.2 → 1.309.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 (168) hide show
  1. package/dist/contracts/agent.contract.d.ts +98 -4
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/agent.contract.js +40 -10
  4. package/dist/contracts/agent.contract.js.map +1 -1
  5. package/dist/contracts/agents.contract.d.ts +285 -0
  6. package/dist/contracts/agents.contract.d.ts.map +1 -1
  7. package/dist/contracts/approvals.contract.d.ts +34 -0
  8. package/dist/contracts/approvals.contract.d.ts.map +1 -1
  9. package/dist/contracts/approvals.contract.js +30 -1
  10. package/dist/contracts/approvals.contract.js.map +1 -1
  11. package/dist/contracts/device.contract.d.ts +1 -0
  12. package/dist/contracts/device.contract.d.ts.map +1 -1
  13. package/dist/contracts/extensions.contract.d.ts +390 -0
  14. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  15. package/dist/contracts/extensions.contract.js +11 -1
  16. package/dist/contracts/extensions.contract.js.map +1 -1
  17. package/dist/contracts/runner.contract.d.ts +117 -107
  18. package/dist/contracts/runner.contract.d.ts.map +1 -1
  19. package/dist/contracts/sessions.contract.d.ts +1 -0
  20. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  21. package/dist/contracts/settings.contract.d.ts +54 -0
  22. package/dist/contracts/settings.contract.d.ts.map +1 -1
  23. package/dist/contracts/system.contract.d.ts +210 -0
  24. package/dist/contracts/system.contract.d.ts.map +1 -1
  25. package/dist/contracts/system.contract.js +35 -1
  26. package/dist/contracts/system.contract.js.map +1 -1
  27. package/dist/events/agent-events.d.ts +9 -2
  28. package/dist/events/agent-events.d.ts.map +1 -1
  29. package/dist/events/agent-events.js +5 -1
  30. package/dist/events/agent-events.js.map +1 -1
  31. package/dist/events/resume.d.ts +1 -0
  32. package/dist/events/resume.d.ts.map +1 -1
  33. package/dist/events/resume.js +2 -0
  34. package/dist/events/resume.js.map +1 -1
  35. package/dist/events/system-events.d.ts +40 -0
  36. package/dist/events/system-events.d.ts.map +1 -1
  37. package/dist/events/transcript.d.ts +14 -0
  38. package/dist/events/transcript.d.ts.map +1 -1
  39. package/dist/events/transcript.js +7 -1
  40. package/dist/events/transcript.js.map +1 -1
  41. package/dist/index.d.ts +1141 -71
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/models/model-label.d.ts +2 -0
  46. package/dist/models/model-label.d.ts.map +1 -0
  47. package/dist/models/model-label.js +20 -0
  48. package/dist/models/model-label.js.map +1 -0
  49. package/dist/models/model-pins.d.ts +0 -1
  50. package/dist/models/model-pins.d.ts.map +1 -1
  51. package/dist/models/model-pins.js +0 -2
  52. package/dist/models/model-pins.js.map +1 -1
  53. package/dist/policy/command-classes.d.ts.map +1 -1
  54. package/dist/policy/command-classes.js +88 -7
  55. package/dist/policy/command-classes.js.map +1 -1
  56. package/dist/policy/reserved-servers.d.ts +7 -0
  57. package/dist/policy/reserved-servers.d.ts.map +1 -0
  58. package/dist/policy/reserved-servers.js +21 -0
  59. package/dist/policy/reserved-servers.js.map +1 -0
  60. package/dist/policy/turned-away.d.ts.map +1 -1
  61. package/dist/policy/turned-away.js.map +1 -1
  62. package/dist/protocol/ingress-protocol.d.ts +2 -0
  63. package/dist/protocol/ingress-protocol.d.ts.map +1 -1
  64. package/dist/protocol/ingress-protocol.js +4 -1
  65. package/dist/protocol/ingress-protocol.js.map +1 -1
  66. package/dist/protocol/peer-mcp-server.d.ts +7 -0
  67. package/dist/protocol/peer-mcp-server.d.ts.map +1 -1
  68. package/dist/protocol/peer-mcp-server.js +6 -1
  69. package/dist/protocol/peer-mcp-server.js.map +1 -1
  70. package/dist/protocol/tunnel-heartbeat.d.ts +19 -0
  71. package/dist/protocol/tunnel-heartbeat.d.ts.map +1 -0
  72. package/dist/protocol/tunnel-heartbeat.js +41 -0
  73. package/dist/protocol/tunnel-heartbeat.js.map +1 -0
  74. package/dist/schemas/agent.d.ts +59 -0
  75. package/dist/schemas/agent.d.ts.map +1 -1
  76. package/dist/schemas/agent.js +45 -2
  77. package/dist/schemas/agent.js.map +1 -1
  78. package/dist/schemas/agents.d.ts +60 -0
  79. package/dist/schemas/agents.d.ts.map +1 -1
  80. package/dist/schemas/agents.js +6 -1
  81. package/dist/schemas/agents.js.map +1 -1
  82. package/dist/schemas/approvals.d.ts +67 -0
  83. package/dist/schemas/approvals.d.ts.map +1 -1
  84. package/dist/schemas/approvals.js +43 -0
  85. package/dist/schemas/approvals.js.map +1 -1
  86. package/dist/schemas/automations.d.ts +20 -0
  87. package/dist/schemas/automations.d.ts.map +1 -1
  88. package/dist/schemas/devices.d.ts +2 -0
  89. package/dist/schemas/devices.d.ts.map +1 -1
  90. package/dist/schemas/devices.js +1 -0
  91. package/dist/schemas/devices.js.map +1 -1
  92. package/dist/schemas/extension-updates.d.ts +850 -77
  93. package/dist/schemas/extension-updates.d.ts.map +1 -1
  94. package/dist/schemas/extension-updates.js +23 -0
  95. package/dist/schemas/extension-updates.js.map +1 -1
  96. package/dist/schemas/history.d.ts +1 -0
  97. package/dist/schemas/history.d.ts.map +1 -1
  98. package/dist/schemas/history.js +4 -0
  99. package/dist/schemas/history.js.map +1 -1
  100. package/dist/schemas/metrics.d.ts +258 -0
  101. package/dist/schemas/metrics.d.ts.map +1 -1
  102. package/dist/schemas/metrics.js +95 -0
  103. package/dist/schemas/metrics.js.map +1 -1
  104. package/dist/schemas/providers/plan-limits.d.ts +42 -1
  105. package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
  106. package/dist/schemas/providers/plan-limits.js +48 -1
  107. package/dist/schemas/providers/plan-limits.js.map +1 -1
  108. package/dist/schemas/providers/usage.d.ts +2 -0
  109. package/dist/schemas/providers/usage.d.ts.map +1 -1
  110. package/dist/schemas/providers/usage.js +2 -0
  111. package/dist/schemas/providers/usage.js.map +1 -1
  112. package/dist/schemas/settings.d.ts +52 -0
  113. package/dist/schemas/settings.d.ts.map +1 -1
  114. package/dist/schemas/settings.js +11 -0
  115. package/dist/schemas/settings.js.map +1 -1
  116. package/dist/schemas/turn-break.d.ts +5 -0
  117. package/dist/schemas/turn-break.d.ts.map +1 -1
  118. package/dist/schemas/turn-break.js +4 -0
  119. package/dist/schemas/turn-break.js.map +1 -1
  120. package/dist/state/definition.d.ts +8 -0
  121. package/dist/state/definition.d.ts.map +1 -1
  122. package/dist/state/history-state.d.ts.map +1 -1
  123. package/dist/state/history-state.js +3 -0
  124. package/dist/state/history-state.js.map +1 -1
  125. package/dist/text/transcript-fold.d.ts +1 -1
  126. package/dist/text/transcript-fold.d.ts.map +1 -1
  127. package/dist/text/transcript-fold.js +19 -9
  128. package/dist/text/transcript-fold.js.map +1 -1
  129. package/package.json +16 -5
  130. package/src/contracts/agent.contract.ts +62 -15
  131. package/src/contracts/approvals.contract.ts +35 -1
  132. package/src/contracts/extensions.contract.ts +14 -0
  133. package/src/contracts/system.contract.ts +42 -1
  134. package/src/events/agent-events.ts +10 -2
  135. package/src/events/resume.test.ts +8 -0
  136. package/src/events/resume.ts +4 -0
  137. package/src/events/transcript.ts +13 -2
  138. package/src/index.ts +2 -0
  139. package/src/models/model-label.test.ts +24 -0
  140. package/src/models/model-label.ts +29 -0
  141. package/src/models/model-pins.ts +0 -6
  142. package/src/policy/command-classes.test.ts +99 -1
  143. package/src/policy/command-classes.ts +143 -11
  144. package/src/policy/reserved-servers.test.ts +51 -0
  145. package/src/policy/reserved-servers.ts +48 -0
  146. package/src/policy/turned-away.ts +2 -1
  147. package/src/protocol/ingress-protocol.test.ts +78 -1
  148. package/src/protocol/ingress-protocol.ts +8 -1
  149. package/src/protocol/peer-mcp-server.test.ts +10 -3
  150. package/src/protocol/peer-mcp-server.ts +14 -1
  151. package/src/protocol/tunnel-heartbeat.test.ts +76 -0
  152. package/src/protocol/tunnel-heartbeat.ts +67 -0
  153. package/src/schemas/agent.ts +77 -5
  154. package/src/schemas/agents.ts +11 -1
  155. package/src/schemas/approvals.ts +67 -0
  156. package/src/schemas/context-trim.test.ts +2 -2
  157. package/src/schemas/devices.ts +2 -0
  158. package/src/schemas/extension-updates.ts +30 -0
  159. package/src/schemas/history.ts +8 -1
  160. package/src/schemas/metrics.ts +148 -0
  161. package/src/schemas/providers/plan-limits.ts +67 -2
  162. package/src/schemas/providers/usage.ts +3 -0
  163. package/src/schemas/settings.ts +16 -0
  164. package/src/schemas/turn-break.ts +8 -0
  165. package/src/state/history-state.ts +6 -0
  166. package/src/state/workspace-state.test.ts +2 -2
  167. package/src/text/transcript-fold.test.ts +25 -7
  168. package/src/text/transcript-fold.ts +32 -19
@@ -12,11 +12,22 @@ export const MCP_PROTOCOL_VERSION = "2025-06-18";
12
12
  // whole surface. A failed tool returns isError, never a JSON-RPC error. Each tool's zod schema is both what tools/list
13
13
  // advertises and what a call is checked against.
14
14
 
15
+ // What one call can do to the world: `read` changes nothing, so a runtime may run several at once and allow it where
16
+ // writes are held; `write` changes something recoverable; `destructive` may lose something.
17
+ export type ToolEffect = "read" | "write" | "destructive";
18
+
19
+ // MCP tool annotations for an effect. Both hints are always spelled out: an absent destructiveHint reads as true.
20
+ export const toolAnnotations = (effect: ToolEffect): { readonly readOnlyHint: boolean; readonly destructiveHint: boolean } => ({
21
+ readOnlyHint: effect === "read",
22
+ destructiveHint: effect === "destructive",
23
+ });
24
+
15
25
  export interface McpTool<Ctx> {
16
26
  readonly name: string;
17
27
  readonly description: string;
18
28
  // JSON Schema for `tools/list`, derived from the zod schema once at module load, not per request.
19
29
  readonly inputSchema: Record<string, unknown>;
30
+ readonly annotations: ReturnType<typeof toolAnnotations>;
20
31
  readonly call: (args: unknown, ctx: Ctx) => Promise<Record<string, unknown>>;
21
32
  }
22
33
 
@@ -48,6 +59,7 @@ export const converterReadable = (value: unknown): unknown => {
48
59
  export const tool = <Schema extends z.ZodType, Ctx>(spec: {
49
60
  readonly name: string;
50
61
  readonly description: string;
62
+ readonly effect: ToolEffect;
51
63
  readonly input: Schema;
52
64
  readonly run: (args: z.output<Schema>, ctx: Ctx) => Promise<Record<string, unknown>>;
53
65
  }): McpTool<Ctx> => {
@@ -56,6 +68,7 @@ export const tool = <Schema extends z.ZodType, Ctx>(spec: {
56
68
  name: spec.name,
57
69
  description: spec.description,
58
70
  inputSchema: converterReadable(inputSchema) as Record<string, unknown>,
71
+ annotations: toolAnnotations(spec.effect),
59
72
  call: async (args, ctx) => {
60
73
  const parsed = spec.input.safeParse(args);
61
74
  // Readable enough for a model to fix its own call: which field, and what was expected.
@@ -92,7 +105,7 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
92
105
  // mid-session takes effect on the next call.
93
106
  export const createMcpServer = <Ctx>(spec: McpServerSpec<Ctx>): ((message: unknown, ctx: Ctx) => Promise<Record<string, unknown> | undefined>) => {
94
107
  const byName = new Map(spec.tools.map((entry) => [entry.name, entry]));
95
- const listing = spec.tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
108
+ const listing = spec.tools.map(({ name, description, inputSchema, annotations }) => ({ name, description, inputSchema, annotations }));
96
109
  const audited = async (entry: McpAuditEntry): Promise<void> => {
97
110
  try {
98
111
  await spec.audit(entry);
@@ -0,0 +1,76 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+ import { createHeartbeat, DEAD_AFTER_MS } from "./tunnel-heartbeat.js";
3
+
4
+ // The clock is injected, so these read as "what happens after N seconds of silence" rather than as timer
5
+ // plumbing — which is the only question the heartbeat answers.
6
+ const world = (deadAfterMs = DEAD_AFTER_MS) => {
7
+ let clock = 0;
8
+ const ping = mock();
9
+ const onDead = mock();
10
+ const heartbeat = createHeartbeat({ ping, onDead, deadAfterMs, now: () => clock });
11
+ return { heartbeat, ping, onDead, advance: (ms: number) => (clock += ms) };
12
+ };
13
+
14
+ describe(`createHeartbeat`, () => {
15
+ test(`pings a peer that is still answering`, () => {
16
+ const { heartbeat, ping, onDead } = world();
17
+ heartbeat.tick();
18
+ heartbeat.tick();
19
+
20
+ expect(ping).toHaveBeenCalledTimes(2);
21
+ expect(onDead).toHaveBeenCalledTimes(0);
22
+ });
23
+
24
+ // Three missed intervals, not one: a single dropped pong is ordinary on a congested link, and tearing down
25
+ // a working tunnel for it is the more expensive mistake.
26
+ test(`survives a lost pong inside the dead window`, () => {
27
+ const { heartbeat, onDead, advance } = world();
28
+ advance(DEAD_AFTER_MS - 1);
29
+ heartbeat.tick();
30
+
31
+ expect(onDead).toHaveBeenCalledTimes(0);
32
+ expect(heartbeat.alive()).toBe(true);
33
+ });
34
+
35
+ test(`declares a peer dead once it has been silent past the window`, () => {
36
+ const { heartbeat, ping, onDead, advance } = world();
37
+ advance(DEAD_AFTER_MS + 1);
38
+ heartbeat.tick();
39
+
40
+ expect(onDead).toHaveBeenCalledTimes(1);
41
+ // Not pinged on the way out: a peer that has gone quiet is not sent one more frame it will never answer.
42
+ expect(ping).toHaveBeenCalledTimes(0);
43
+ expect(heartbeat.alive()).toBe(false);
44
+ });
45
+
46
+ // Any frame counts as life, not just a pong: a tunnel carrying traffic is alive by definition.
47
+ test(`a frame from the peer restarts the window`, () => {
48
+ const { heartbeat, onDead, advance } = world();
49
+ advance(DEAD_AFTER_MS - 1);
50
+ heartbeat.saw();
51
+ advance(DEAD_AFTER_MS - 1);
52
+ heartbeat.tick();
53
+
54
+ expect(onDead).toHaveBeenCalledTimes(0);
55
+ });
56
+
57
+ // The socket is torn down once; a tick that raced the teardown must not report it dead a second time.
58
+ test(`reports death once, and never after stopping`, () => {
59
+ const { heartbeat, onDead, advance } = world();
60
+ advance(DEAD_AFTER_MS + 1);
61
+ heartbeat.tick();
62
+ heartbeat.tick();
63
+
64
+ expect(onDead).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ test(`a stopped heartbeat neither pings nor fires`, () => {
68
+ const { heartbeat, ping, onDead, advance } = world();
69
+ heartbeat.stop();
70
+ advance(DEAD_AFTER_MS + 1);
71
+ heartbeat.tick();
72
+
73
+ expect(ping).toHaveBeenCalledTimes(0);
74
+ expect(onDead).toHaveBeenCalledTimes(0);
75
+ });
76
+ });
@@ -0,0 +1,67 @@
1
+ // Detects a tunnel peer TCP won't report as gone (a killed container, a dropped NAT mapping, a host that slept), from
2
+ // both ends: the edge forgets a silent sandbox and the sandbox redials a silent edge, and only each end can do its half.
3
+ // A state machine, not a timer, so `tick` (one interval) can be driven directly by tests.
4
+
5
+ // Ping every 15s, dead after three missed intervals (45s); one miss alone is ordinary on a congested link.
6
+ export const PING_INTERVAL_MS = 15_000;
7
+ export const DEAD_AFTER_MS = 45_000;
8
+
9
+ export interface HeartbeatOptions {
10
+ readonly ping: () => void;
11
+ readonly onDead: () => void;
12
+ readonly now?: () => number;
13
+ readonly deadAfterMs?: number;
14
+ }
15
+
16
+ export interface Heartbeat {
17
+ // Peer said something; any frame counts, since a busy tunnel is alive even if it drops a pong.
18
+ readonly saw: () => void;
19
+ // One interval elapsed: ping the peer, or declare it dead.
20
+ readonly tick: () => void;
21
+ readonly stop: () => void;
22
+ readonly alive: () => boolean;
23
+ }
24
+
25
+ export const createHeartbeat = (options: HeartbeatOptions): Heartbeat => {
26
+ const now = options.now ?? Date.now;
27
+ const deadAfterMs = options.deadAfterMs ?? DEAD_AFTER_MS;
28
+ let lastSeen = now();
29
+ let stopped = false;
30
+
31
+ return {
32
+ saw: () => {
33
+ lastSeen = now();
34
+ },
35
+ tick: () => {
36
+ if (stopped) {
37
+ return;
38
+ }
39
+ // Declared dead before pinging: a peer already quiet gets no extra frame, and the deadline window stays
40
+ // exact.
41
+ if (now() - lastSeen > deadAfterMs) {
42
+ stopped = true;
43
+ options.onDead();
44
+ return;
45
+ }
46
+ options.ping();
47
+ },
48
+ stop: () => {
49
+ stopped = true;
50
+ },
51
+ alive: () => !stopped,
52
+ };
53
+ };
54
+
55
+ // Wired to a real clock; unrefed so a heartbeat never keeps the process alive.
56
+ export const startHeartbeat = (options: HeartbeatOptions & { readonly intervalMs?: number }): Heartbeat => {
57
+ const heartbeat = createHeartbeat(options);
58
+ const timer = setInterval(() => heartbeat.tick(), options.intervalMs ?? PING_INTERVAL_MS);
59
+ timer.unref();
60
+ return {
61
+ ...heartbeat,
62
+ stop: () => {
63
+ clearInterval(timer);
64
+ heartbeat.stop();
65
+ },
66
+ };
67
+ };
@@ -91,7 +91,8 @@ export const CommandClassSchema = z.enum([
91
91
  "secrets.access",
92
92
  // Publishes outward and irreversibly: npm/pnpm/yarn/cargo publish, gh release create, docker push.
93
93
  "package.publish",
94
- // curl/wget to a non-local address, the general exfiltration channel under the per-provider actionRules.
94
+ // A network program (curl, nc, ssh, scp, …) or interpreter one-liner aimed at a non-loopback or run-time-built
95
+ // destination, the general exfiltration channel under the per-provider actionRules.
95
96
  "network.outbound",
96
97
  ]);
97
98
  export type CommandClass = z.infer<typeof CommandClassSchema>;
@@ -128,6 +129,15 @@ export type ForkedFrom = z.infer<typeof ForkedFromSchema>;
128
129
  // The turn's fields before the cross-field refinements below, so a subset (TurnProfileSchema) can be picked from them.
129
130
  const AgentTurnFieldsSchema = z.object({
130
131
  prompt: z.string().describe("What to say to the agent. May be empty if you are only attaching files."),
132
+ // Minted by the sender so a send whose answer was lost can be sent again without being delivered twice.
133
+ messageId: z
134
+ .string()
135
+ .min(1)
136
+ .max(128)
137
+ .optional()
138
+ .describe(
139
+ "Your id for this message. Sending again under an id the sandbox already took is answered with what it did with it the first time, never a second delivery. Leave it out and the sandbox names the message itself.",
140
+ ),
131
141
  // Seeds a fresh registry entry's title; an existing entry's title always wins over this.
132
142
  title: z
133
143
  .string()
@@ -392,16 +402,78 @@ export type ModelPin = z.infer<typeof ModelPinSchema>;
392
402
  // POST /agent's ack: the daemon-minted id of the detached run it started. The turn runs daemon-side regardless of any
393
403
  // client connection; every window renders it via /agent/attach.
394
404
  export const StartedTurnSchema = z.object({
395
- run: z.string().describe("The id of the run that just started. Hand it back when you attach, so the stream resumes rather than replaying."),
405
+ run: z
406
+ .string()
407
+ .describe(
408
+ "The id of the run that just started. Hand it back when you attach: the stream always opens on the conversation's newest run, so a different id there means another turn has started since.",
409
+ ),
396
410
  });
397
411
  export type StartedTurn = z.infer<typeof StartedTurnSchema>;
398
- // Attaches to a conversation's run (live, or finished within retention); no cursor to resume from, the head carries all
399
- // rows. `run` names what the client was watching, so a newer run's head id tells it to catch up.
412
+ // What the sandbox did with one message, keyed by the id its sender gave it: the same id sent again gets this answer
413
+ // back as a duplicate, never a second delivery.
414
+ export const MessageReceiptSchema = z.object({
415
+ delivered: z
416
+ .enum(["started", "steered", "queued"])
417
+ .describe(
418
+ "What became of the message: it started a turn, it was said into the turn already running, or it waits in the conversation's queue for the next one.",
419
+ ),
420
+ run: z
421
+ .string()
422
+ .optional()
423
+ .describe(
424
+ "The run the message is in: the turn it started, or the one it was said into. Hand it back when you attach. Absent while the message waits in the queue.",
425
+ ),
426
+ duplicate: z
427
+ .literal(true)
428
+ .optional()
429
+ .describe("The sandbox had already taken a message under this id: this is what became of it, and nothing new happened."),
430
+ });
431
+ export type MessageReceipt = z.infer<typeof MessageReceiptSchema>;
432
+ // Who a message to a conversation is from: a person (a composer, an API caller), the sandbox itself (a watch that fired,
433
+ // a job that ended, a land that broke something), or another agent (a child reporting back).
434
+ export const MessageVoiceSchema = z.enum(["person", "sandbox", "agent"]);
435
+ export type MessageVoice = z.infer<typeof MessageVoiceSchema>;
436
+ // One message waiting for the conversation's next turn, as every window shows it.
437
+ export const QueuedMessageSchema = z.object({
438
+ id: z.string().describe("The message's id: what its sender named it, or what the sandbox did."),
439
+ text: z.string().describe("The words, as they will go out."),
440
+ attachments: z.array(z.string()).optional().describe("Files that go with it, as workspace paths."),
441
+ voice: MessageVoiceSchema.describe("Who it is from: a person, the sandbox itself, or another agent."),
442
+ queuedAt: z.number().describe("When it joined the queue, in milliseconds."),
443
+ revision: z
444
+ .number()
445
+ .int()
446
+ .nonnegative()
447
+ .describe("The queue's revision when this message was last written. An edit or a removal names it, and is refused if the message has changed since."),
448
+ });
449
+ export type QueuedMessage = z.infer<typeof QueuedMessageSchema>;
450
+ // Why a queue holds its messages rather than letting them go when the conversation is free.
451
+ export const QueuePauseSchema = z.enum(["stopped", "refused"]);
452
+ export type QueuePause = z.infer<typeof QueuePauseSchema>;
453
+ // What waits for a conversation's next turn: messages that arrived while a turn that could not take them ran, and
454
+ // held ones. Conversation state, the same for every window and kept across a restart.
455
+ export const ConversationQueueSchema = z.object({
456
+ items: z.array(QueuedMessageSchema).describe("What waits, in the order it goes out."),
457
+ revision: z.number().int().nonnegative().describe("Moves with every change to the queue, so of two copies the higher is the newer."),
458
+ paused: QueuePauseSchema.optional().describe(
459
+ "Why nothing goes out by itself: somebody stopped the turn, or the turn these messages started was refused before it ran. Resuming, or sending another message, lets them go.",
460
+ ),
461
+ });
462
+ export type ConversationQueue = z.infer<typeof ConversationQueueSchema>;
463
+ // What resuming a queue did: started a turn with what waited, when nothing else ran.
464
+ export const QueueResumedSchema = z.object({
465
+ run: z.string().optional().describe("The turn the waiting messages started. Absent when a turn was already running, and they go after it."),
466
+ });
467
+ export type QueueResumed = z.infer<typeof QueueResumedSchema>;
468
+ // Attaches to a conversation's newest run (live, or finished within retention); no cursor to resume from, the head
469
+ // carries all rows. `run` names what the client was watching, so a newer run's head id tells it to catch up.
400
470
  export const AttachTurnSchema = z.object({
401
471
  conversationId: ConversationIdSchema.describe("Which conversation to watch."),
402
472
  run: z
403
473
  .string()
404
474
  .optional()
405
- .describe("The run you were watching. If a newer turn has started since, the head names that one instead, and its rows are that turn's."),
475
+ .describe(
476
+ "The run you were watching. The stream opens on the conversation's newest run whatever you name: if a newer turn has started since, the head names that one instead, and its rows are that turn's.",
477
+ ),
406
478
  });
407
479
  export type AttachTurn = z.infer<typeof AttachTurnSchema>;
@@ -1,6 +1,6 @@
1
1
  // agents: the conversation fleet
2
2
  import { z } from "zod";
3
- import { AgentHarnessSchema, AgentOriginSchema, AgentProviderSchema, ForkedFromSchema } from "./agent.js";
3
+ import { AgentHarnessSchema, AgentOriginSchema, AgentProviderSchema, ConversationQueueSchema, ForkedFromSchema } from "./agent.js";
4
4
  import { LoopStateSchema } from "./loops.js";
5
5
  import { LimitPolicySchema, RetryPolicySchema, TurnBreakPolicySchema, TurnBreakSchema } from "./turn-break.js";
6
6
  import { EMOJI_MAX_LENGTH, isSingleEmoji } from "../text/emoji.js";
@@ -351,6 +351,16 @@ export const AgentSummarySchema = z.object({
351
351
  "What this conversation's merged work is called, once the drafting above has finished. It arrives on the same push that ends the draft, so the promise and the answer travel together.",
352
352
  ),
353
353
  startedAt: z.number().optional().describe("When the running turn started, in milliseconds. Absent when none is running."),
354
+ run: z
355
+ .string()
356
+ .optional()
357
+ .describe(
358
+ "The run under way right now: what a stop names, so it cannot cancel a turn that started after it was pressed. Absent when none is running, and for a turn with no run to attach to.",
359
+ ),
360
+ // On the card because every window and the board read it here: the queue is the conversation's, not any window's.
361
+ queue: ConversationQueueSchema.optional().describe(
362
+ "Messages waiting for its next turn, and whether they are held. Absent for a conversation nothing has ever waited for.",
363
+ ),
354
364
  updatedAt: z.number().describe("When it last did something, in milliseconds. Reading it does not count."),
355
365
  // Daemon-side, not browser-side: read state is a fact about the work, so clearing site data or switching devices
356
366
  // can't resurrect a badge.
@@ -124,3 +124,70 @@ export const ApprovalsListSchema = z.object({
124
124
  export type ApprovalsList = z.infer<typeof ApprovalsListSchema>;
125
125
  // entryId, not a bare string: the id becomes a filename under .intentic/config/approvals/.
126
126
  export const ApprovalIdParamSchema = z.object({ id: entryId.describe("Which approval.") });
127
+
128
+ /* WORKSPACE HOOKS: what Claude Code would run on a turn from its settings files and skill or subagent definitions, held
129
+ * off until the owner approves that exact set by its digest. Never a workspace file: whoever can write the hooks must not
130
+ * also be able to write their yes, so the record lives with the daemon and only the owner's routes below move it. */
131
+
132
+ const hookDigest = z.string().regex(/^[0-9a-f]{64}$/);
133
+
134
+ export const SettingsHookSchema = z.object({
135
+ source: z
136
+ .enum(["user", "project"])
137
+ .describe("Whose configuration declares it: the sandbox's own (~/.claude) or the workspace's (.claude/ in the project)."),
138
+ declaredIn: z
139
+ .string()
140
+ .optional()
141
+ .describe(
142
+ "The skill, subagent or command whose frontmatter declares it, spelled like a script path. Absent when it comes from the settings.json of its source.",
143
+ ),
144
+ event: z.string().describe("When it runs, in Claude Code's own words: before a tool, after one, when a prompt is sent, when a session starts."),
145
+ matcher: z.string().optional().describe("Which tools it is limited to, when it is limited at all."),
146
+ type: z.string().describe("What kind of hook it is: a shell command, an address it calls, a prompt it asks a model."),
147
+ run: z.string().describe("Exactly what it runs: the command line, the address, the prompt."),
148
+ });
149
+ export type SettingsHook = z.infer<typeof SettingsHookSchema>;
150
+
151
+ export const HookScriptSchema = z.object({
152
+ path: z
153
+ .string()
154
+ .describe(
155
+ "A file one of the hooks runs, spelled the way the hook names it: inside the workspace as $CLAUDE_PROJECT_DIR/…, under the home directory as ~/…, otherwise absolute.",
156
+ ),
157
+ sha256: z
158
+ .string()
159
+ .describe(
160
+ "Its contents when the hooks were found. The approval covers these bytes, so editing the file asks again, the same as editing the command would.",
161
+ ),
162
+ });
163
+ export type HookScript = z.infer<typeof HookScriptSchema>;
164
+
165
+ export const HookRequestSchema = z.object({
166
+ digest: hookDigest.describe(
167
+ "The set's fingerprint: every hook as declared plus the bytes of every file they run. Approving it approves exactly this, and nothing that differs from it by a character.",
168
+ ),
169
+ seenAt: z.number().describe("When a turn first found this set, in milliseconds."),
170
+ conversationId: z.string().optional().describe("The conversation whose turn found it, when one did."),
171
+ hooks: z
172
+ .array(SettingsHookSchema)
173
+ .describe("Every hook in the set. Until it is approved, turns run with all of them off, and so with every other hook the agent would load."),
174
+ scripts: z.array(HookScriptSchema).describe("The files those hooks run by name, which the approval pins byte for byte."),
175
+ dismissed: z
176
+ .boolean()
177
+ .optional()
178
+ .describe("Kept off on purpose: no longer counted as waiting, and still approvable. Present only when it was dismissed."),
179
+ });
180
+ export type HookRequest = z.infer<typeof HookRequestSchema>;
181
+
182
+ export const HookRequestsSchema = z.object({
183
+ requests: z.array(HookRequestSchema).describe("Hook sets waiting for a yes, newest first, then the dismissed ones."),
184
+ ledgerUnreadable: z
185
+ .boolean()
186
+ .optional()
187
+ .describe(
188
+ "The record of what was approved could not be read, so no settings-file hook runs anywhere until a set is approved again. Present only when that is the case.",
189
+ ),
190
+ });
191
+ export type HookRequests = z.infer<typeof HookRequestsSchema>;
192
+
193
+ export const HookDigestParamSchema = z.object({ digest: hookDigest.describe("Which hook set, by its fingerprint.") });
@@ -5,10 +5,10 @@ import { contextTrimLine } from "./context-trim.js";
5
5
  // thin, what it lost, and whether the reader's own instructions were among the losses.
6
6
 
7
7
  test("the window comes first, then what went, then what stayed", () => {
8
- const line = contextTrimLine({ window: 16_384, omitted: ["Map of this project", "How this sandbox asks agents to work"], base: true });
8
+ const line = contextTrimLine({ window: 16_384, omitted: ["Map of this project", "Working in this sandbox"], base: true });
9
9
 
10
10
  expect(line).toContain("16k window");
11
- expect(line).toContain("Map of this project, How this sandbox asks agents to work");
11
+ expect(line).toContain("Map of this project, Working in this sandbox");
12
12
  // Without this clause the line reads as "your AGENTS.md may not have arrived", which is the one thing it is not.
13
13
  expect(line).toContain("Your workspace rules");
14
14
  });
@@ -88,6 +88,8 @@ export const DeviceSandboxFlowSchema = z.object({
88
88
  pair: z.string().optional().meta({ secret: true }),
89
89
  // Required by `reconnect` and `create`; single-sandbox and short-lived, so a leak only buys one recreate.
90
90
  setupCode: z.string().optional().meta({ secret: true }),
91
+ // `reconnect`/`create` only, daemon-filled: the platform that minted `setupCode`, the only one that redeems it.
92
+ platformUrl: z.string().optional(),
91
93
  // `runner-up` only, daemon-filled: `definition` carries no capabilities or secrets; `overlay`/`overlayHash` are
92
94
  // re-verified by hash before build.
93
95
  definition: z.string().optional(),
@@ -206,6 +206,31 @@ export const InvalidWorkspaceExtensionSchema = z.object({
206
206
  error: z.string().describe("Why it could not be read."),
207
207
  });
208
208
  export type InvalidWorkspaceExtension = z.infer<typeof InvalidWorkspaceExtensionSchema>;
209
+ // A workspace extension nobody has approved in its current shape. It stands in for the install moment a folder
210
+ // otherwise never has: until the owner says yes, none of its code runs and none of its contributions are wired.
211
+ export const PendingWorkspaceExtensionSchema = z.object({
212
+ id: extensionId.describe("The extension's id."),
213
+ dir: z.string().describe("Which folder under .intentic/config/workspace-extensions/."),
214
+ manifest: ExtensionManifestSchema.describe("What it declares about itself."),
215
+ powers: PowersDiffSchema.describe(
216
+ "What saying yes allows, as plain sentences. Against what was approved before when something was: `added` is what it asks for now that it did not then. Never approved before, everything it declares is `added`.",
217
+ ),
218
+ approvedBefore: z
219
+ .boolean()
220
+ .describe("An earlier shape of it was approved, and the powers it declares have changed since, which is what put it back here."),
221
+ digest: z
222
+ .string()
223
+ .regex(/^[0-9a-f]{64}$/)
224
+ .describe("The fingerprint of the powers shown, sent back with the approval so a change made while you were reading is caught rather than approved."),
225
+ });
226
+ export type PendingWorkspaceExtension = z.infer<typeof PendingWorkspaceExtensionSchema>;
227
+ export const ExtensionApproveInputSchema = z.object({
228
+ id: extensionId.describe("Which extension."),
229
+ digest: z
230
+ .string()
231
+ .regex(/^[0-9a-f]{64}$/)
232
+ .describe("The fingerprint of the powers you read, from the pending list. A mismatch means they changed since, and nothing is approved."),
233
+ });
209
234
  export const ExtensionsListSchema = z.object({
210
235
  extensions: z.array(ExtensionSummarySchema).describe("What is installed."),
211
236
  invalid: z
@@ -213,6 +238,11 @@ export const ExtensionsListSchema = z.object({
213
238
  .describe(
214
239
  "Extensions written here that could not be read at all. Listed rather than dropped, because there is no install moment at which to reject a broken one, so this is its only way of saying anything.",
215
240
  ),
241
+ pending: z
242
+ .array(PendingWorkspaceExtensionSchema)
243
+ .describe(
244
+ "Extensions written in this workspace that wait for the owner's approval before anything of theirs runs: never approved, or approved when they declared less than they do now.",
245
+ ),
216
246
  updatesCheckedAt: z
217
247
  .string()
218
248
  .optional()
@@ -25,7 +25,8 @@ export interface SnapshotTurn {
25
25
  }
26
26
  export const SnapshotsListSchema = z.object({ snapshots: z.array(SnapshotSchema).describe("Every point you can go back to, newest first.") });
27
27
  // Restores the workspace to that turn's checkpoint, drops every message after it, and forgets the provider session so
28
- // the next turn opens fresh.
28
+ // the next turn opens fresh. `messageId` is what makes `index` safe to act on: a position is only as current as the
29
+ // transcript it was read from.
29
30
  export const RewindTurnSchema = z.object({
30
31
  conversationId: z.string().min(1).describe("Which conversation to rewind."),
31
32
  index: z
@@ -35,6 +36,12 @@ export const RewindTurnSchema = z.object({
35
36
  .describe(
36
37
  "Which message to go back to, counting from the start. It is also how many messages survive: rewinding to the first keeps none of them and puts the files back to before it ran.",
37
38
  ),
39
+ messageId: z
40
+ .string()
41
+ .min(1)
42
+ .describe(
43
+ "The id of the message at that position, as its row names it. If that position now holds a different message, nothing is rewound: the transcript has moved since you read it.",
44
+ ),
38
45
  });
39
46
  export const RewindResultSchema = z.object({
40
47
  snapshot: z
@@ -105,3 +105,151 @@ export const SandboxMetricsSchema = z.object({
105
105
  .describe("Every process in the sandbox but the daemon, by what kind of work it is. A kind with nothing running is absent."),
106
106
  });
107
107
  export type SandboxMetrics = z.infer<typeof SandboxMetricsSchema>;
108
+
109
+ // What is filling the sandbox's disk, by what it is for (GET /system/storage). Sizes are apparent bytes, and a file
110
+ // with several hard links counts once, toward the first place the scan met it.
111
+
112
+ // Every category a byte on the sandbox's volumes can belong to; STORAGE_CLEANABILITY below says which may be cleaned.
113
+ export const STORAGE_CATEGORIES = [
114
+ // The workspace's own files: repositories, their dependencies, anything the owner or an agent put there.
115
+ "workspace",
116
+ // Transcripts, runtime session stores, attachments and loop memory of every conversation, archived ones included.
117
+ "conversations",
118
+ // Each open conversation's own checkout of the repositories, and the dependencies its turns installed over them.
119
+ "checkouts",
120
+ // The minute-by-minute and per-turn snapshots the History timeline restores from.
121
+ "restorePoints",
122
+ // The repositories' own git data: every commit and branch, pushed or not.
123
+ "repositories",
124
+ // Installed versions of the agent runtimes: the one turns use and the one kept for Revert.
125
+ "engines",
126
+ // Search indexes and caches the sandbox keeps up to date by itself while it runs.
127
+ "indexes",
128
+ // Installed extensions and their scratch.
129
+ "extensions",
130
+ // The sandbox's own Docker: images, containers, volumes.
131
+ "docker",
132
+ // Settings, credentials, sign-ins and the ledgers of what was spent and done.
133
+ "state",
134
+ // Anything no part of the sandbox claims.
135
+ "other",
136
+ // Git data and checkouts of repositories deleted from the workspace.
137
+ "trash",
138
+ // Copies of the sandbox packed for download.
139
+ "exports",
140
+ // Files agents produced: generated images, reports, harness output.
141
+ "artifacts",
142
+ // Screenshots and page snapshots the agent's browser took.
143
+ "browserCaptures",
144
+ // The agent browser's profiles, with the sites it is signed in to.
145
+ "browserProfiles",
146
+ // Weights downloaded for local models.
147
+ "modelWeights",
148
+ // The sandbox's own logs and terminal captures.
149
+ "logs",
150
+ // Scratch space agents and checks leave behind.
151
+ "scratch",
152
+ // Package managers' content stores, which the next install refills.
153
+ "packageStores",
154
+ // Build tools' output caches.
155
+ "buildCaches",
156
+ ] as const;
157
+ export const StorageCategoryIdSchema = z.enum(STORAGE_CATEGORIES);
158
+ export type StorageCategoryId = z.infer<typeof StorageCategoryIdSchema>;
159
+
160
+ // none: never removed from here; safe: comes back by itself, removed without asking; confirm: removed only once the
161
+ // owner has read what it costs.
162
+ export const StorageCleanabilitySchema = z.enum(["none", "safe", "confirm"]);
163
+ export type StorageCleanability = z.infer<typeof StorageCleanabilitySchema>;
164
+
165
+ // Which categories may be cleaned, and which ask first: the daemon's cleaner refuses by it, the editor words its
166
+ // confirm by it. State, credentials and live work are `none`; what regenerates is `safe`; the owner's own data asks.
167
+ export const STORAGE_CLEANABILITY = {
168
+ workspace: "none",
169
+ conversations: "none",
170
+ // A live conversation's working copy; archiving the conversation is what frees it.
171
+ checkouts: "none",
172
+ // No compaction is safe to offer: `git gc --auto` already runs after every snapshot, and every object is reachable.
173
+ restorePoints: "none",
174
+ repositories: "none",
175
+ // The Engines card keeps two versions and owns which is which; removing one here would strand its Revert.
176
+ engines: "none",
177
+ // Held open by what maintains them: removing a file frees nothing until they close it.
178
+ indexes: "none",
179
+ extensions: "none",
180
+ docker: "none",
181
+ state: "none",
182
+ other: "none",
183
+ // Nothing reads it again, but it may hold commits never pushed anywhere.
184
+ trash: "confirm",
185
+ exports: "confirm",
186
+ artifacts: "confirm",
187
+ browserCaptures: "confirm",
188
+ browserProfiles: "confirm",
189
+ modelWeights: "confirm",
190
+ logs: "safe",
191
+ scratch: "safe",
192
+ packageStores: "safe",
193
+ buildCaches: "safe",
194
+ } as const satisfies Record<StorageCategoryId, StorageCleanability>;
195
+
196
+ export const StorageItemSchema = z.object({
197
+ path: z.string().describe("Where it is, as an absolute path inside the sandbox."),
198
+ bytes: z.number().describe("Its size in bytes."),
199
+ });
200
+ export type StorageItem = z.infer<typeof StorageItemSchema>;
201
+
202
+ export const StorageCategoryUsageSchema = z.object({
203
+ id: StorageCategoryIdSchema,
204
+ cleanability: StorageCleanabilitySchema.describe("Whether this category can be cleaned from here, and whether cleaning it asks first."),
205
+ bytes: z.number().describe("Its size in bytes."),
206
+ files: z.number().describe("How many files it holds."),
207
+ cleanableBytes: z
208
+ .number()
209
+ .optional()
210
+ .describe(
211
+ "What cleaning it would free right now: only what is old enough and not in use. Absent where nothing here may be cleaned, and for a package store, whose own tool decides what no project needs.",
212
+ ),
213
+ items: z.array(StorageItemSchema).describe("Its biggest parts, largest first, at most eight."),
214
+ });
215
+ export type StorageCategoryUsage = z.infer<typeof StorageCategoryUsageSchema>;
216
+
217
+ export const StorageScanSchema = z.object({
218
+ startedAt: z.number().describe("When the scan began, in milliseconds."),
219
+ finishedAt: z.number().describe("When it ended, in milliseconds: the moment these sizes describe."),
220
+ outcome: z
221
+ .enum(["complete", "partial"])
222
+ .describe("`partial` when the scan hit its time limit first, so every size is at least what it says rather than exactly it."),
223
+ disk: z
224
+ .object({
225
+ usedBytes: z.number().describe("Space used on the volume the workspace lives on."),
226
+ totalBytes: z.number().describe("That volume's size."),
227
+ })
228
+ .optional()
229
+ .describe("The volume as a whole. Absent when the volume would not say."),
230
+ categories: z.array(StorageCategoryUsageSchema).describe("Every category that holds anything, largest first."),
231
+ unreadable: z.number().describe("Files and folders the scan could not read, and so did not count."),
232
+ });
233
+ export type StorageScan = z.infer<typeof StorageScanSchema>;
234
+
235
+ export const StorageReportSchema = z.object({
236
+ scan: StorageScanSchema.optional().describe("The last scan that finished. Absent until one has, and again after the daemon restarts."),
237
+ scanning: z.boolean().describe("Whether a scan is running now."),
238
+ });
239
+ export type StorageReport = z.infer<typeof StorageReportSchema>;
240
+
241
+ export const StorageCleanInputSchema = z.object({
242
+ category: StorageCategoryIdSchema.describe("The category to clean; one whose cleanability is `none` is refused."),
243
+ });
244
+ export type StorageCleanInput = z.infer<typeof StorageCleanInputSchema>;
245
+
246
+ export const StorageCleanResultSchema = z.object({
247
+ category: StorageCategoryIdSchema,
248
+ freedBytes: z.number().describe("Space the removals gave back, in bytes. A file that is still linked elsewhere frees nothing and is not counted."),
249
+ removed: z.number().describe("How many items were removed."),
250
+ kept: z
251
+ .number()
252
+ .describe("How many were left in place: changed too recently, in use by a running program, or no longer this category's."),
253
+ failed: z.number().describe("How many removals the filesystem refused."),
254
+ });
255
+ export type StorageCleanResult = z.infer<typeof StorageCleanResultSchema>;