@intentic/sandbox-contract 1.341.0 → 1.342.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 (96) hide show
  1. package/README.md +4 -0
  2. package/dist/contracts/agent.contract.d.ts +54 -0
  3. package/dist/contracts/agent.contract.d.ts.map +1 -1
  4. package/dist/contracts/agent.contract.js +11 -1
  5. package/dist/contracts/agent.contract.js.map +1 -1
  6. package/dist/contracts/agents.contract.d.ts +17 -0
  7. package/dist/contracts/agents.contract.d.ts.map +1 -1
  8. package/dist/contracts/runner.contract.d.ts +123 -123
  9. package/dist/contracts/settings.contract.d.ts +284 -6
  10. package/dist/contracts/settings.contract.d.ts.map +1 -1
  11. package/dist/contracts/system.contract.d.ts +33 -30
  12. package/dist/contracts/system.contract.d.ts.map +1 -1
  13. package/dist/contracts/workspace.contract.d.ts +9 -0
  14. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  15. package/dist/contracts/workspace.contract.js +11 -1
  16. package/dist/contracts/workspace.contract.js.map +1 -1
  17. package/dist/events/system-events.d.ts +2 -0
  18. package/dist/events/system-events.d.ts.map +1 -1
  19. package/dist/index.d.ts +367 -6
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/models/agent-standing.d.ts +1 -0
  22. package/dist/models/agent-standing.d.ts.map +1 -1
  23. package/dist/models/agent-standing.js +4 -1
  24. package/dist/models/agent-standing.js.map +1 -1
  25. package/dist/protocol/raw/raw-routes.d.ts +4 -0
  26. package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
  27. package/dist/protocol/raw/raw-routes.js +1 -0
  28. package/dist/protocol/raw/raw-routes.js.map +1 -1
  29. package/dist/schemas/agent.d.ts +4 -0
  30. package/dist/schemas/agent.d.ts.map +1 -1
  31. package/dist/schemas/agent.js +4 -0
  32. package/dist/schemas/agent.js.map +1 -1
  33. package/dist/schemas/agents-list.d.ts +1 -0
  34. package/dist/schemas/agents-list.d.ts.map +1 -1
  35. package/dist/schemas/agents.d.ts +3 -0
  36. package/dist/schemas/agents.d.ts.map +1 -1
  37. package/dist/schemas/dev-rebuild-log.d.ts +11 -0
  38. package/dist/schemas/dev-rebuild-log.d.ts.map +1 -0
  39. package/dist/schemas/dev-rebuild-log.js +30 -0
  40. package/dist/schemas/dev-rebuild-log.js.map +1 -0
  41. package/dist/schemas/devices.d.ts +1 -10
  42. package/dist/schemas/devices.d.ts.map +1 -1
  43. package/dist/schemas/devices.js +1 -29
  44. package/dist/schemas/devices.js.map +1 -1
  45. package/dist/schemas/keep-warm.d.ts +7 -0
  46. package/dist/schemas/keep-warm.d.ts.map +1 -1
  47. package/dist/schemas/keep-warm.js +25 -0
  48. package/dist/schemas/keep-warm.js.map +1 -1
  49. package/dist/schemas/providers/plan-limits.d.ts +27 -0
  50. package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
  51. package/dist/schemas/providers/plan-limits.js +23 -0
  52. package/dist/schemas/providers/plan-limits.js.map +1 -1
  53. package/dist/schemas/providers/usage.d.ts +4 -0
  54. package/dist/schemas/providers/usage.d.ts.map +1 -1
  55. package/dist/schemas/providers/usage.js +4 -0
  56. package/dist/schemas/providers/usage.js.map +1 -1
  57. package/dist/schemas/settings-experiments.d.ts +15 -0
  58. package/dist/schemas/settings-experiments.d.ts.map +1 -0
  59. package/dist/schemas/settings-experiments.js +52 -0
  60. package/dist/schemas/settings-experiments.js.map +1 -0
  61. package/dist/schemas/settings.d.ts +293 -13
  62. package/dist/schemas/settings.d.ts.map +1 -1
  63. package/dist/schemas/settings.js +13 -40
  64. package/dist/schemas/settings.js.map +1 -1
  65. package/dist/schemas/workspace/workspace-tree.d.ts +10 -0
  66. package/dist/schemas/workspace/workspace-tree.d.ts.map +1 -1
  67. package/dist/schemas/workspace/workspace-tree.js +15 -0
  68. package/dist/schemas/workspace/workspace-tree.js.map +1 -1
  69. package/dist/state/definition.d.ts +40 -12
  70. package/dist/state/definition.d.ts.map +1 -1
  71. package/dist/text/preview-guide.d.ts +15 -0
  72. package/dist/text/preview-guide.d.ts.map +1 -0
  73. package/dist/text/preview-guide.js +138 -0
  74. package/dist/text/preview-guide.js.map +1 -0
  75. package/dist/time/zone.d.ts +1 -0
  76. package/dist/time/zone.d.ts.map +1 -1
  77. package/dist/time/zone.js +6 -0
  78. package/dist/time/zone.js.map +1 -1
  79. package/package.json +5 -5
  80. package/src/contracts/agent.contract.ts +13 -0
  81. package/src/contracts/workspace.contract.ts +15 -0
  82. package/src/models/agent-standing.ts +21 -3
  83. package/src/protocol/raw/raw-routes.ts +3 -0
  84. package/src/schemas/agent.ts +6 -0
  85. package/src/schemas/dev-rebuild-log.test.ts +1 -1
  86. package/src/schemas/dev-rebuild-log.ts +58 -0
  87. package/src/schemas/devices.ts +2 -54
  88. package/src/schemas/keep-warm.ts +34 -0
  89. package/src/schemas/providers/plan-limits.ts +29 -0
  90. package/src/schemas/providers/usage.ts +9 -0
  91. package/src/schemas/settings-experiments.ts +80 -0
  92. package/src/schemas/settings.ts +24 -53
  93. package/src/schemas/workspace/workspace-tree.ts +20 -0
  94. package/src/text/preview-guide.ts +159 -0
  95. package/src/time/zone.test.ts +18 -1
  96. package/src/time/zone.ts +12 -0
@@ -35,6 +35,8 @@ import {
35
35
  WorkspaceMediaTicketQuerySchema,
36
36
  WorkspaceMediaTicketSchema,
37
37
  WorkspaceDownloadTicketQuerySchema,
38
+ WorkspacePreviewTicketQuerySchema,
39
+ WorkspacePreviewTicketSchema,
38
40
  WorkspaceDownloadTicketSchema,
39
41
  WorkspaceMoveSchema,
40
42
  WorkspaceResolveQuerySchema,
@@ -134,6 +136,19 @@ export const workspaceContract = {
134
136
  .meta({ floor: "viewer", guest: true })
135
137
  .input(WorkspaceDownloadTicketQuerySchema)
136
138
  .output(WorkspaceDownloadTicketSchema),
139
+ // Mints the ticket GET /workspace/preview/{ticket}/* (a plain Hono route serving a page and its files) requires.
140
+ previewTicket: procedure
141
+ .route({
142
+ method: "POST",
143
+ path: "/workspace/preview-ticket",
144
+ summary: "Get an address to preview a web page at",
145
+ description:
146
+ "Mints a short-lived address a frame loads a workspace web page from, so the page runs as it would on a static file server: its scripts, modules, fetches and links resolve against real addresses beside it. The address serves the files of the page's own repository, never hidden files or the sandbox's own state, and the page runs sandboxed, with no access to the editor or this API.",
147
+ })
148
+ // Previewing a page is reading it and the files beside it, and the ticket is narrower than the bearer.
149
+ .meta({ floor: "viewer", guest: true })
150
+ .input(WorkspacePreviewTicketQuerySchema)
151
+ .output(WorkspacePreviewTicketSchema),
137
152
  // Matches a prose-written path, often just a suffix, against the tree rather than trusting it as root-relative.
138
153
  resolve: procedure
139
154
  .route({
@@ -111,7 +111,8 @@ export const limited = (agent: AgentStanding): boolean => agent.status === "erro
111
111
 
112
112
  // A message held at the door because the sandbox is short of memory (turn-admission): nothing ran and nothing failed,
113
113
  // it waits for a person's "send anyway". The daemon files it as an `error`, so this is read off the code, as `limited`
114
- // is, and the chip and glyph say held rather than error; the lane stays Attention, since the press is the reader's.
114
+ // is, and the chip and glyph say held rather than error; the lane stays Attention, since the press is the reader's,
115
+ // until its words are booked for later (bookingAnswers).
115
116
  export const memoryHeld = (agent: Pick<AgentStanding, "status" | "failureCode">): boolean =>
116
117
  agent.status === "error" && agent.failureCode === "sandbox-memory-low";
117
118
 
@@ -218,13 +219,29 @@ export const writingNow = (agent: AgentStanding): boolean => agent.status === "r
218
219
  // `stopped`/`interrupted` leave a half-written worktree only a new message can carry forward; `stopping` is
219
220
  // counted the moment the press lands rather than once the unwind finishes, since it settles in the same lane
220
221
  // either way; `failed` is the same kind of dead end one step earlier, before a worktree existed. A spent allowance
221
- // counts until something is booked to carry it on (limitScheduled): a resend at the reset, or a move now.
222
+ // counts until something is booked to carry it on (limitScheduled): a resend at the reset, or a move now. Any settled
223
+ // dead end counts until a booking answers it (bookingAnswers).
222
224
  const BLOCKING_ENDINGS: ReadonlySet<AgentStatus | ClientAgentStatus> = new Set(["error", "interrupted", "stopping", "stopped", "failed"]);
223
225
 
224
226
  // A land that broke and still stands: its work is stuck on the branch, whatever the turn after it did, so the card is
225
227
  // the reader's to look at. Not while a turn or a land is under way, which ends in another land of its own.
226
228
  export const landBroken = (agent: AgentStanding): boolean => agent.landFailure !== undefined && !turnInFlight(agent);
227
229
 
230
+ // The settled dead ends a booking can answer. Not `stopping`, which is still unwinding and rests in Attention whatever
231
+ // waits behind it.
232
+ const ANSWERABLE_ENDINGS: ReadonlySet<AgentStatus | ClientAgentStatus> = new Set(["error", "interrupted", "stopped", "failed"]);
233
+
234
+ /**
235
+ * A turn that ended badly, with something booked to go by itself after it: a message for a time, a continuation at a
236
+ * spent allowance's reopen, words held for memory re-timed to that reopen. That booking is the person's answer to the
237
+ * ending, or the sandbox's on their standing word, so the next move is the sandbox's, at the booking, and the ending no
238
+ * longer calls the reader: the card is Active, and its corner says when it goes. Before 2026-10-18 only the sandbox's own
239
+ * booked resend after a spent allowance (limitScheduled) answered an ending, so an expired account's card stayed in
240
+ * Attention under a clock already saying "sends Sun 05:00", the reader's answer to it booked and not counted. Answers
241
+ * only the ending: an ask (a plan, a question, a permission, a land only the reader can clear) still calls them.
242
+ */
243
+ export const bookingAnswers = (agent: AgentStanding): boolean => ANSWERABLE_ENDINGS.has(agent.status) && scheduledSend(agent);
244
+
228
245
  export const blocked = (agent: AgentStanding): boolean =>
229
246
  limitScheduled(agent)
230
247
  ? false
@@ -238,7 +255,7 @@ export const blocked = (agent: AgentStanding): boolean =>
238
255
  (agent.attention.need === true && agent.status !== "running") ||
239
256
  landBroken(agent) ||
240
257
  heldByPrompt(agent) ||
241
- BLOCKING_ENDINGS.has(agent.status);
258
+ (BLOCKING_ENDINGS.has(agent.status) && !bookingAnswers(agent));
242
259
 
243
260
  // The half of `blocked` that is literally waiting on an answer (plan, question, permission, capability), narrower
244
261
  // than `blocked`: dead ends (a failed turn, an unlandable conflict) want looking at but owe the user nothing, so
@@ -273,6 +290,7 @@ export const heardByParent = (child: AgentStanding & { readonly updatedAt: numbe
273
290
  child.status !== "stopping" &&
274
291
  // Booked to run again by itself: not over, whatever its parent made of the pause.
275
292
  !limitScheduled(child) &&
293
+ !bookingAnswers(child) &&
276
294
  !child.attention.question &&
277
295
  !child.attention.conflict &&
278
296
  !landBroken(child) &&
@@ -40,6 +40,9 @@ export const RAW_ROUTES = {
40
40
  "GET /workspace/media": { auth: "door", guest: true, lane: "bulk" },
41
41
  // Navigated to for a ZIP of a selection, so no header either: the ticket it carries names the selection.
42
42
  "GET /workspace/download": { auth: "door", guest: true, lane: "bulk" },
43
+ // A previewed web page and the files it names, loaded by a frame, which sends no header: the ticket is the path's
44
+ // first segment, so the page's relative addresses carry it, and it is checked in the handler.
45
+ "GET /workspace/preview/{ticket}/*": { auth: "door", guest: true },
43
46
  // An attachment is part of the message it rides with; any other target edits the shared tree.
44
47
  "POST /workspace/upload": { floor: "writer", attachmentFloor: "collaborator", lane: "bulk" },
45
48
  "POST /workspace/upload-diff": {},
@@ -533,6 +533,12 @@ export const QueuedMessageSchema = z.object({
533
533
  after: ConversationIdSchema.optional().describe(
534
534
  "The conversation whose finished work must land before this message goes out by itself, in place of `until`.",
535
535
  ),
536
+ continues: z
537
+ .literal(true)
538
+ .optional()
539
+ .describe(
540
+ "A continuation, not words: when it goes, the conversation carries on from where its last turn stopped, as `agent.run` with `continues` does, and nothing is added to it as anybody's message. `text` is empty. A turn that starts in the conversation before it goes makes it moot, and it is dropped.",
541
+ ),
536
542
  });
537
543
  export type QueuedMessage = z.infer<typeof QueuedMessageSchema>;
538
544
  // Why a queue holds its messages rather than letting them go when the conversation is free.
@@ -1,4 +1,4 @@
1
- import { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, devRebuildLogPath, readDevRebuildLog } from "./devices.js";
1
+ import { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, devRebuildLogPath, readDevRebuildLog } from "./dev-rebuild-log.js";
2
2
 
3
3
  // The one account a detached rebuild gives of itself. The daemon builds the shell that writes this text and the browser
4
4
  // draws what it means, so the grammar is pinned here, where both of them read it.
@@ -0,0 +1,58 @@
1
+ // What a detached `dev-rebuild` leaves behind on the machine that runs it, and how `dev-rebuild-log` reads it back.
2
+ // devices.ts re-exports all of it beside the device commands that name it.
3
+
4
+ // WHERE A DETACHED REBUILD REPORTS ITSELF. `dev-rebuild` returns the moment the build is under way and nothing streams
5
+ // back from it — a cold build outruns any timeout this door has, and the container swap at the end kills the daemon that
6
+ // would have read the answer — so the build writes here instead, in the folder ic logs its own recreates into, and
7
+ // `dev-rebuild-log` reads it back. Spelled once, here, because the daemon builds both command lines from it and the
8
+ // browser names the same path to a reader whose rebuild never came back.
9
+ export const devRebuildLogPath = (slug: string): string => `~/.intentic/logs/dev-rebuild-${slug}.log`;
10
+
11
+ // The build's own shell appends this when the build ends, with its exit status: since the log outlives the daemon, the
12
+ // container and the page that started it, this mark is the only thing that can say a rebuild is OVER rather than slow.
13
+ export const DEV_REBUILD_EXIT_MARK = "@intentic-rebuild-exit";
14
+ // `dev-rebuild-log` prints this first, then how many seconds it is since the log last grew — `-` when there is no log on
15
+ // that machine at all. A running build goes quiet for a minute at a time inside a docker layer; a build whose machine
16
+ // slept never writes its exit mark, and only this tells the two apart.
17
+ export const DEV_REBUILD_QUIET_MARK = "@intentic-rebuild-quiet";
18
+
19
+ export interface DevRebuildLog {
20
+ /** Nothing has ever rebuilt this sandbox from a checkout on that machine. */
21
+ readonly missing: boolean;
22
+ /** Seconds since the log last grew; undefined when the machine's `stat` wouldn't say. */
23
+ readonly quietFor: number | undefined;
24
+ /** The build's own exit status, present only once it has ended. */
25
+ readonly exitCode: number | undefined;
26
+ /** The tail as the machine printed it, both marks removed. */
27
+ readonly lines: readonly string[];
28
+ }
29
+
30
+ // Reads `dev-rebuild-log`'s stdout. Shared rather than parsed at the reader, so the shell line that produces this and the
31
+ // card that draws it cannot drift apart. Unparseable is never an error: an answer with no marks at all is simply a log
32
+ // with nothing to say yet.
33
+ const markValue = (line: string, mark: string): string | undefined => (line.startsWith(`${mark} `) ? line.slice(mark.length + 1).trim() : undefined);
34
+ const wholeNumber = (value: string): number | undefined => (/^\d+$/.test(value) ? Number(value) : undefined);
35
+
36
+ export const readDevRebuildLog = (text: string): DevRebuildLog => {
37
+ let missing = false;
38
+ let quietFor: number | undefined;
39
+ let exitCode: number | undefined;
40
+ const lines: string[] = [];
41
+ for (const line of text.split(/\r?\n/)) {
42
+ const quiet = markValue(line, DEV_REBUILD_QUIET_MARK);
43
+ const exit = markValue(line, DEV_REBUILD_EXIT_MARK);
44
+ if (quiet !== undefined) {
45
+ missing = quiet === "-";
46
+ quietFor = wholeNumber(quiet);
47
+ } else if (exit !== undefined) {
48
+ // A mark whose status is unreadable still means the build ENDED, and not well.
49
+ exitCode = wholeNumber(exit) ?? 1;
50
+ } else {
51
+ lines.push(line);
52
+ }
53
+ }
54
+ while (lines.at(-1)?.trim() === "") {
55
+ lines.pop();
56
+ }
57
+ return { missing, quietFor, exitCode, lines };
58
+ };
@@ -266,61 +266,9 @@ export type DeviceCommand = z.infer<typeof DeviceCommandSchema>;
266
266
  export const DeviceSyncSwitchSchema = DeviceCommandSchema.exclude(["dev-restart", "dev-rebuild", "dev-rebuild-log", "sync-install", "sync-clean"]);
267
267
  export type DeviceSyncSwitch = z.infer<typeof DeviceSyncSwitchSchema>;
268
268
 
269
- // WHERE A DETACHED REBUILD REPORTS ITSELF. `dev-rebuild` returns the moment the build is under way and nothing streams
270
- // back from it — a cold build outruns any timeout this door has, and the container swap at the end kills the daemon that
271
- // would have read the answer — so the build writes here instead, in the folder ic logs its own recreates into, and
272
- // `dev-rebuild-log` reads it back. Spelled once, here, because the daemon builds both command lines from it and the
273
- // browser names the same path to a reader whose rebuild never came back.
274
- export const devRebuildLogPath = (slug: string): string => `~/.intentic/logs/dev-rebuild-${slug}.log`;
275
-
276
- // The build's own shell appends this when the build ends, with its exit status: since the log outlives the daemon, the
277
- // container and the page that started it, this mark is the only thing that can say a rebuild is OVER rather than slow.
278
- export const DEV_REBUILD_EXIT_MARK = "@intentic-rebuild-exit";
279
- // `dev-rebuild-log` prints this first, then how many seconds it is since the log last grew — `-` when there is no log on
280
- // that machine at all. A running build goes quiet for a minute at a time inside a docker layer; a build whose machine
281
- // slept never writes its exit mark, and only this tells the two apart.
282
- export const DEV_REBUILD_QUIET_MARK = "@intentic-rebuild-quiet";
283
-
284
- export interface DevRebuildLog {
285
- /** Nothing has ever rebuilt this sandbox from a checkout on that machine. */
286
- readonly missing: boolean;
287
- /** Seconds since the log last grew; undefined when the machine's `stat` wouldn't say. */
288
- readonly quietFor: number | undefined;
289
- /** The build's own exit status, present only once it has ended. */
290
- readonly exitCode: number | undefined;
291
- /** The tail as the machine printed it, both marks removed. */
292
- readonly lines: readonly string[];
293
- }
269
+ // Where a detached rebuild reports itself, and the reader of that report (dev-rebuild-log.ts).
270
+ export { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, type DevRebuildLog, devRebuildLogPath, readDevRebuildLog } from "./dev-rebuild-log.js";
294
271
 
295
- // Reads `dev-rebuild-log`'s stdout. Shared rather than parsed at the reader, so the shell line that produces this and the
296
- // card that draws it cannot drift apart. Unparseable is never an error: an answer with no marks at all is simply a log
297
- // with nothing to say yet.
298
- const markValue = (line: string, mark: string): string | undefined => (line.startsWith(`${mark} `) ? line.slice(mark.length + 1).trim() : undefined);
299
- const wholeNumber = (value: string): number | undefined => (/^\d+$/.test(value) ? Number(value) : undefined);
300
-
301
- export const readDevRebuildLog = (text: string): DevRebuildLog => {
302
- let missing = false;
303
- let quietFor: number | undefined;
304
- let exitCode: number | undefined;
305
- const lines: string[] = [];
306
- for (const line of text.split(/\r?\n/)) {
307
- const quiet = markValue(line, DEV_REBUILD_QUIET_MARK);
308
- const exit = markValue(line, DEV_REBUILD_EXIT_MARK);
309
- if (quiet !== undefined) {
310
- missing = quiet === "-";
311
- quietFor = wholeNumber(quiet);
312
- } else if (exit !== undefined) {
313
- // A mark whose status is unreadable still means the build ENDED, and not well.
314
- exitCode = wholeNumber(exit) ?? 1;
315
- } else {
316
- lines.push(line);
317
- }
318
- }
319
- while (lines.at(-1)?.trim() === "") {
320
- lines.pop();
321
- }
322
- return { missing, quietFor, exitCode, lines };
323
- };
324
272
  // Machine's sandbox id (absent = every paired sandbox); must start alphanumeric, else it parses as a CLI flag.
325
273
  export const DeviceSandboxIdSchema = z
326
274
  .string()
@@ -131,3 +131,37 @@ export type PromptFingerprint = z.infer<typeof PromptFingerprintSchema>;
131
131
  /** Which parts differ between two fingerprints, by name; empty when they match. */
132
132
  export const changedParts = (before: PromptFingerprint, after: PromptFingerprint): string[] =>
133
133
  [...new Set([...Object.keys(before.parts), ...Object.keys(after.parts)])].filter((name) => before.parts[name] !== after.parts[name]).toSorted();
134
+
135
+ // keep-warm's knobs, one object since each only means anything beside the others (cache-keepwarm.ts).
136
+ export const KeepWarmSettingsSchema = z.object({
137
+ auto: z
138
+ .boolean()
139
+ .default(false)
140
+ .describe(
141
+ "Keep a Claude conversation's prompt cache warm after each turn a person asked for. Each refresh re-reads the cached context at the cache price and adds nothing to the conversation.",
142
+ ),
143
+ hours: z
144
+ .number()
145
+ .min(1)
146
+ .max(8)
147
+ .default(4)
148
+ .describe(
149
+ "How long an idle conversation is kept warm after its last turn, in hours, and what a press on one offers first. Shortened where refreshing would cost more than the cold resume it saves, and at midnight where the agent runs, when the date in its prompt changes.",
150
+ ),
151
+ minTokens: z
152
+ .number()
153
+ .int()
154
+ .min(0)
155
+ .default(100_000)
156
+ .describe("Only conversations at least this large, in tokens, are kept warm by `auto`: a small one is cheap to re-read anyway."),
157
+ reserve: z
158
+ .number()
159
+ .int()
160
+ .min(0)
161
+ .max(90)
162
+ .default(15)
163
+ .describe(
164
+ "How much of an account's usage limit, in percent, keeping conversations warm must leave untouched for real work. Refreshing stops once any limit that account's model spends is fuller than that.",
165
+ ),
166
+ });
167
+ export type KeepWarmSettings = z.infer<typeof KeepWarmSettingsSchema>;
@@ -484,6 +484,35 @@ export const QueueScheduleSchema = z
484
484
  message: "a conversation cannot wait for its own work to land",
485
485
  });
486
486
  export type QueueSchedule = z.infer<typeof QueueScheduleSchema>;
487
+ // Books the conversation to carry on from where its last turn stopped, at an instant or once another conversation's work
488
+ // lands: a Continue (`agent.run` with `continues`) that waits in the queue, where every window draws it as scheduled,
489
+ // rather than words. What a spent allowance's "Continue at 05:00" books, where it once booked the word "Continue".
490
+ export const QueueContinueSchema = z
491
+ .object({
492
+ conversationId: z.string().min(1).describe("Which conversation carries on."),
493
+ sendAt: z
494
+ .number()
495
+ .int()
496
+ .positive()
497
+ .optional()
498
+ .describe("Carry on at this instant (epoch milliseconds). At most a month ahead; an instant already past carries on now."),
499
+ sendAfter: ConversationIdSchema.optional().describe("Carry on once that conversation has finished and all of its work has landed, instead."),
500
+ routing: ResumeRoutingSchema.optional().describe(
501
+ "Who serves the turn it starts. Leave it out to carry on where the conversation runs when it goes.",
502
+ ),
503
+ messageId: z
504
+ .string()
505
+ .min(1)
506
+ .optional()
507
+ .describe("Names the booking, so the same press sent twice books it once."),
508
+ })
509
+ .refine((input) => (input.sendAt === undefined) !== (input.sendAfter === undefined), {
510
+ message: "name exactly one of sendAt and sendAfter",
511
+ })
512
+ .refine((input) => input.sendAfter !== input.conversationId, {
513
+ message: "a conversation cannot wait for its own work to land",
514
+ });
515
+ export type QueueContinue = z.infer<typeof QueueContinueSchema>;
487
516
  // Moves a conversation to another account of the provider it runs on: the one command that changes who pays, so the
488
517
  // account a conversation runs on is always the daemon's record and never a window's guess.
489
518
  export const SwitchAccountSchema = z.object({
@@ -96,6 +96,15 @@ export const UsageTurnSchema = z.object({
96
96
  // Hash of the brief that was sent, recorded on control turns too. Load-bearing rather than decorative: the file is
97
97
  // rewritten monthly, so a 30-day window holds two different treatments and pooling them would measure neither.
98
98
  notesCohort: z.string().optional(),
99
+ // Arm of the tool-search experiment, stable per conversation: true loads connected tools' schemas on demand, false
100
+ // sends them all with every request. Absent where the turn was not a native Claude one on the Claude Code loop.
101
+ toolSearchArm: z.boolean().optional(),
102
+ // Arm of the stable-prompt experiment, stable per conversation: true says the guidance that moves between turns on
103
+ // the message when it changed, false keeps it in the system block. Absent outside the Claude Code loop.
104
+ stablePromptArm: z.boolean().optional(),
105
+ // Assigned on cold-eligible follow-ups only, including controls; cleared results only when a copy was resumed.
106
+ coldTrimArm: z.boolean().optional(),
107
+ coldTrimmed: z.number().int().min(0).optional(),
99
108
  // What became of pre-turn retrieval on this turn, and how long it took. Assignment and DELIVERY are different
100
109
  // facts: the first version of this mechanism was assigned to every eligible turn and reached four in five of them,
101
110
  // which is the difference between a null result and a mechanism that never ran. Absent means the flag was off, so
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+
3
+ // Agent experiments share conversation-stable arms; the sandbox settings schema owns their persisted shape.
4
+ export const claudeExperimentSettings = {
5
+ // Claude Code turns itself out of tool search whenever its base URL is not Anthropic's own host, and the privacy
6
+ // shield's gateway is a loopback address, so every shielded native Claude turn paid every MCP tool's schema on every
7
+ // call (2026-10-18: a median 83k-token prompt before the first tool ran). The gateway forwards `tool_reference`
8
+ // blocks and the beta header untouched, so this asks for it by name. Native Claude only: a translator or minted
9
+ // endpoint may not speak the beta.
10
+ toolSearch: z
11
+ .boolean()
12
+ .default(true)
13
+ .describe(
14
+ "Load the schemas of connected tools (browsers, devices, accounts, phones) only when the agent looks one up, instead of sending all of them with every request. The tools the agent leans on every turn stay loaded. Applies to Claude models on the Claude Code loop.",
15
+ ),
16
+ // Both arms set the CLI's switch explicitly, so a shielded turn and an unshielded one compare the same two things.
17
+ toolSearchHoldout: z
18
+ .number()
19
+ .min(0)
20
+ .max(1)
21
+ .default(0.5)
22
+ .describe(
23
+ "What share of conversations to run with every tool loaded up front, so the two can be compared. Whole conversations rather than individual turns, because switching mid-conversation would rewrite the prompt the cache holds.",
24
+ ),
25
+ // Until 2026-10-18 this switch was read by nothing. The guidance paragraphs whose condition moves between turns (who
26
+ // is watching, which of the owner's machines, browsers and phones are online) sat in the system block, so each move
27
+ // rewrote the whole conversation to the provider's cache, and where Claude Code records a session's system prompt
28
+ // once the move never reached the model. Now they ride the turn's message, said when they changed.
29
+ stableSystemPrompt: z
30
+ .boolean()
31
+ .default(true)
32
+ .describe(
33
+ "Keep the instructions identical between turns so the provider can cache them, saying what changes between turns (who is watching, which of your computers, browsers and phones are connected) on the message instead, and only when it changed. Applies to the Claude Code loop.",
34
+ ),
35
+ stableSystemPromptHoldout: z
36
+ .number()
37
+ .min(0)
38
+ .max(1)
39
+ .default(0.5)
40
+ .describe(
41
+ "What share of conversations to run with those changing paragraphs kept in the instructions, so the two can be compared. Whole conversations rather than individual turns, because moving them mid-conversation would itself rewrite the cached prompt.",
42
+ ),
43
+ coldTrim: z
44
+ .boolean()
45
+ .default(true)
46
+ .describe(
47
+ "Resume large Claude Code conversations with older tool output cleared once their prompt cache has expired. Keeps the conversation and tool calls, with directions to recover cleared output, and shows a trim notice.",
48
+ ),
49
+ coldTrimHoldout: z
50
+ .number()
51
+ .min(0)
52
+ .max(1)
53
+ .default(0.5)
54
+ .describe(
55
+ "What share of conversations to resume whole when their cache is cold, so opening prompt size and failed tool calls can be compared. Whole conversations keep the same arm across follow-up turns.",
56
+ ),
57
+ coldTrimMinTokens: z
58
+ .number()
59
+ .int()
60
+ .min(0)
61
+ .default(100_000)
62
+ .describe(
63
+ "How many measured context tokens a Claude Code session must exceed before a cold follow-up can clear older tool output. Smaller sessions save little from trimming.",
64
+ ),
65
+ };
66
+
67
+ export const iqSearchSettings = {
68
+ iqSearch: z
69
+ .boolean()
70
+ .default(false)
71
+ .describe("Teach the agent how to use this workspace's own search tool, rather than leaving it to grep around."),
72
+ iqSearchHoldout: z
73
+ .number()
74
+ .min(0)
75
+ .max(1)
76
+ .default(0)
77
+ .describe(
78
+ "What share of conversations to run without that teaching, so the two can be compared. Whole conversations rather than individual turns, because once the teaching is in a session, withholding it from the next request does not make the model forget it.",
79
+ ),
80
+ };
@@ -2,10 +2,12 @@
2
2
  // itself, which those settings adopt.
3
3
  import { STATE_DIR } from "@intentic/constants";
4
4
  import { z } from "zod";
5
+ import { claudeExperimentSettings, iqSearchSettings } from "./settings-experiments.js";
5
6
  import { CommandJudgeModeSchema, ProjectInstallModeSchema } from "../policy/safety-policy.js";
6
7
  import { ModelRoleSchema } from "../models/model-roles.js";
7
8
  import { MODEL_SETUP_ID, ModelSetupSchema } from "../models/model-setups.js";
8
9
  import { AdmissionPolicySchema, AdmissionRuleSchema } from "./agent.js";
10
+ import { KeepWarmSettingsSchema } from "./keep-warm.js";
9
11
  import { LimitHandoffSchema } from "./providers/handoff.js";
10
12
  import { SessionsPlacementSchema } from "./offload.js";
11
13
  import { LimitPolicySchema, RetryPolicySchema, RoomPolicySchema } from "./turn-break.js";
@@ -211,39 +213,8 @@ export type SkillSwitch = z.infer<typeof SkillSwitchSchema>;
211
213
  // picks rather than failing whole. A setting removed or reshaped here gets its conversion in settings-history.ts, which
212
214
  // also names every retired setting, none of whose names may come back.
213
215
 
214
- // keep-warm's knobs, one object since each only means anything beside the others (cache-keepwarm.ts).
215
- export const KeepWarmSettingsSchema = z.object({
216
- auto: z
217
- .boolean()
218
- .default(false)
219
- .describe(
220
- "Keep a Claude conversation's prompt cache warm after each turn a person asked for. Each refresh re-reads the cached context at the cache price and adds nothing to the conversation.",
221
- ),
222
- hours: z
223
- .number()
224
- .min(1)
225
- .max(8)
226
- .default(4)
227
- .describe(
228
- "How long an idle conversation is kept warm after its last turn, in hours, and what a press on one offers first. Shortened where refreshing would cost more than the cold resume it saves, and at midnight where the agent runs, when the date in its prompt changes.",
229
- ),
230
- minTokens: z
231
- .number()
232
- .int()
233
- .min(0)
234
- .default(100_000)
235
- .describe("Only conversations at least this large, in tokens, are kept warm by `auto`: a small one is cheap to re-read anyway."),
236
- reserve: z
237
- .number()
238
- .int()
239
- .min(0)
240
- .max(90)
241
- .default(15)
242
- .describe(
243
- "How much of an account's usage limit, in percent, keeping conversations warm must leave untouched for real work. Refreshing stops once any limit that account's model spends is fuller than that.",
244
- ),
245
- });
246
- export type KeepWarmSettings = z.infer<typeof KeepWarmSettingsSchema>;
216
+ // keep-warm's knobs live with its clock (keep-warm.ts); re-exported, as every settings reader imports them from here.
217
+ export { type KeepWarmSettings, KeepWarmSettingsSchema } from "./keep-warm.js";
247
218
 
248
219
  // Who the editor's screens are written for: `developer` reads git's own words (branch, land, commit), `maker` plain ones
249
220
  // (draft, accept, save) over the same mechanisms. The editor's vocabulary, never anything an agent does. A person's own
@@ -273,12 +244,6 @@ export const SandboxSettingsSchema = z.object({
273
244
  .describe(
274
245
  "Which clock this sandbox's schedules are set by, as a zone name like Europe/Warsaw. Automations that repeat on a clock fire by this, not by the machine's own time. Leave it empty and they fire by UTC, which is almost certainly not what you meant when you typed a time.",
275
246
  ),
276
- stableSystemPrompt: z
277
- .boolean()
278
- .default(false)
279
- .describe(
280
- "Keep the instructions identical between turns so the provider can cache them, moving anything that varies into the message instead. Cheaper, at the cost of some flexibility.",
281
- ),
282
247
  skills: z.array(z.string()).default(["lsp", "fileq"]).describe("Which built-in tools are switched on. A skill of your own is not listed here: it is on while the agent's copy of it exists."),
283
248
  // One half of the reading a new chat gets between send and its first turn (chat-router.ts): with this on, the
284
249
  // message and one line per card are put to the same model that chooses what the chat runs on, in the same call.
@@ -306,6 +271,15 @@ export const SandboxSettingsSchema = z.object({
306
271
  .describe(
307
272
  "Keep the text of every document in the workspace (pdf, Word, OpenDocument, spreadsheets, presentations, epub, and scans where OCR is installed) ready to search. In the background, one low-priority process at a time, each document is rendered to text when it appears or changes, so `rg` and iq find what documents say under their own paths, cited by page. Off, nothing renders until someone opens a document, and searches skip them.",
308
273
  ),
274
+ // Whether the daemon tells its host it is unneeded (system/quiet-watch.ts): off, the work signal carries no
275
+ // `quietSince` and says `keepAwake`, so the machine's keeper never puts it to sleep (ic's `sandbox sleep`). Only a
276
+ // sandbox on somebody's own computer sleeps that way; a hosted machine's idle stop is the plan's and ignores this.
277
+ sleepWhenUnused: z
278
+ .boolean()
279
+ .default(true)
280
+ .describe(
281
+ "Let the computer this sandbox runs on put it to sleep after a stretch nobody needs it (no editor open, no agent working, no terminal typed in, no app of it open), which frees its memory. It starts again when somebody opens it, and a few minutes before anything it has scheduled comes due, so automations still run on time. Off keeps it running. Applies only to a sandbox on your own computer.",
282
+ ),
309
283
  hashlineEdits: z
310
284
  .boolean()
311
285
  .default(false)
@@ -329,18 +303,8 @@ export const SandboxSettingsSchema = z.object({
329
303
  .describe(
330
304
  "Your own instructions, used only when the mode above says custom. Then it is the whole of them: both built-in bases go, and so does everything this product would otherwise add, including the guidance the chat's own cards are driven by. That is the price of total control.",
331
305
  ),
332
- iqSearch: z
333
- .boolean()
334
- .default(false)
335
- .describe("Teach the agent how to use this workspace's own search tool, rather than leaving it to grep around."),
336
- iqSearchHoldout: z
337
- .number()
338
- .min(0)
339
- .max(1)
340
- .default(0)
341
- .describe(
342
- "What share of conversations to run without that teaching, so the two can be compared. Whole conversations rather than individual turns, because once the teaching is in a session, withholding it from the next request does not make the model forget it.",
343
- ),
306
+ ...iqSearchSettings,
307
+ ...claudeExperimentSettings,
344
308
  // Rooted at the run's own starting folder (a persona's, an isolated conversation's worktree), not the workspace
345
309
  // root — it maps the project containing that folder.
346
310
  workspaceMap: z
@@ -738,10 +702,14 @@ export const SavingsArmSchema = z.object({ turns: z.number(), mean: z.number() }
738
702
  // openedWithListing: whether a turn listed directories to orient itself, as a share of turns (the project map).
739
703
  // callsBeforeTarget: how far a turn walked before touching a file it went on to edit.
740
704
  // failedCalls: tool calls that ended in error (the field notes, whose largest section is a failure taxonomy).
705
+ // openingPrompt: tokens a turn's first request sent (cache reads plus cache writes), which is what every later call of
706
+ // the turn reads again (tool search, which moves the tool schemas out of it).
707
+ // openingWrite: tokens a follow-up turn's first request wrote to the cache instead of reading them (the stable prompt).
741
708
  // Every reading is the main agent's own: a subagent's calls are counted toward the turn and read for nothing.
742
- // Never cost: each mechanism moves one small part of a turn's work, inside the noise of the rest.
709
+ // Never cost: each mechanism moves one small part of a turn's work, inside the noise of the rest. The prompt's size is
710
+ // the exception that proves it: it is the part every call pays again, so it is read directly rather than through cost.
743
711
  export const TurnMetricReadingSchema = z.object({
744
- metric: z.enum(["searchCalls", "openingSearches", "openedWithListing", "callsBeforeTarget", "failedCalls"]),
712
+ metric: z.enum(["searchCalls", "openingSearches", "openedWithListing", "callsBeforeTarget", "failedCalls", "openingPrompt", "openingWrite"]),
745
713
  on: SavingsArmSchema,
746
714
  off: SavingsArmSchema,
747
715
  // Additional control turns to reach a fixed target resolution, not today's delta (which inherits noise and always
@@ -810,6 +778,9 @@ export const SavingsReportSchema = z.object({
810
778
  // Same absence rule as `search`: not measured, never zero.
811
779
  map: TurnExperimentSchema.optional(),
812
780
  notes: TurnExperimentSchema.optional(),
781
+ toolSearch: TurnExperimentSchema.optional(),
782
+ stablePrompt: TurnExperimentSchema.optional(),
783
+ coldTrim: TurnExperimentSchema.optional(),
813
784
  });
814
785
  export type SavingsReport = z.infer<typeof SavingsReportSchema>;
815
786
 
@@ -160,6 +160,26 @@ export const WorkspaceDownloadTicketSchema = z.object({
160
160
  expiresAt: z.number().describe("When it stops working, in milliseconds. It is meant to be used at once."),
161
161
  filename: z.string().describe("What the archive is saved as, so a caller can say what is on its way."),
162
162
  });
163
+ // Credential for GET /workspace/preview/{ticket}/*, which a frame loads a web page from so it runs as it would on a
164
+ // static file server: its scripts, modules, fetches and links resolve against real addresses. The ticket is the path's
165
+ // first segment, so every relative address the page names carries it on.
166
+ export const WorkspacePreviewTicketQuerySchema = WorkspaceScopeSchema.extend({
167
+ path: z.string().min(1).describe("The web page to preview, as a workspace path."),
168
+ source: z
169
+ .string()
170
+ .max(8 * 1024 * 1024)
171
+ .optional()
172
+ .describe("The page's text as the editor holds it, when it has edits not yet saved; served in place of the file on disk."),
173
+ });
174
+ export const WorkspacePreviewTicketSchema = z.object({
175
+ page: z.string().describe("The page's address on this daemon, a path to put after its base URL; the ticket is inside it."),
176
+ root: z
177
+ .string()
178
+ .describe(
179
+ "The folder the ticket serves, as a workspace path (empty for the workspace's root): the page's own repository. An address under the ticket names the file at the same place under this folder.",
180
+ ),
181
+ expiresAt: z.number().describe("When it stops working, in milliseconds."),
182
+ });
163
183
  // A read of a window: offset negative reads that many bytes from the end (for following a growing log); limit is
164
184
  // clamped to the daemon's own cap. Coerced from query strings.
165
185
  export const WorkspaceFileReadQuerySchema = WorkspaceScopeSchema.extend({