@intentic/sandbox-contract 1.310.0 → 1.311.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 (118) hide show
  1. package/README.md +38 -159
  2. package/dist/contracts/accounts.contract.d.ts +3 -0
  3. package/dist/contracts/accounts.contract.d.ts.map +1 -1
  4. package/dist/contracts/agent.contract.d.ts +15 -0
  5. package/dist/contracts/agent.contract.d.ts.map +1 -1
  6. package/dist/contracts/agents.contract.d.ts +596 -1
  7. package/dist/contracts/agents.contract.d.ts.map +1 -1
  8. package/dist/contracts/agents.contract.js +13 -2
  9. package/dist/contracts/agents.contract.js.map +1 -1
  10. package/dist/contracts/capabilities.contract.d.ts +2 -0
  11. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  12. package/dist/contracts/device.contract.d.ts +1 -0
  13. package/dist/contracts/device.contract.d.ts.map +1 -1
  14. package/dist/contracts/runner.contract.d.ts +45 -0
  15. package/dist/contracts/runner.contract.d.ts.map +1 -1
  16. package/dist/contracts/settings.contract.d.ts +8 -0
  17. package/dist/contracts/settings.contract.d.ts.map +1 -1
  18. package/dist/contracts/system.contract.d.ts +59 -0
  19. package/dist/contracts/system.contract.d.ts.map +1 -1
  20. package/dist/events/agent-events.d.ts +20 -0
  21. package/dist/events/agent-events.d.ts.map +1 -1
  22. package/dist/events/agent-events.js +6 -1
  23. package/dist/events/agent-events.js.map +1 -1
  24. package/dist/events/system-events.d.ts +48 -0
  25. package/dist/events/system-events.d.ts.map +1 -1
  26. package/dist/events/transcript.d.ts +5 -0
  27. package/dist/events/transcript.d.ts.map +1 -1
  28. package/dist/events/transcript.js +9 -1
  29. package/dist/events/transcript.js.map +1 -1
  30. package/dist/front/generated/wire.d.ts +74 -0
  31. package/dist/front/generated/wire.d.ts.map +1 -0
  32. package/dist/front/generated/wire.js +2 -0
  33. package/dist/front/generated/wire.js.map +1 -0
  34. package/dist/index.d.ts +684 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/policy/command-run.d.ts +1 -1
  39. package/dist/policy/command-run.d.ts.map +1 -1
  40. package/dist/policy/command-run.js +7 -4
  41. package/dist/policy/command-run.js.map +1 -1
  42. package/dist/protocol/ingress-protocol.d.ts.map +1 -1
  43. package/dist/protocol/ingress-protocol.js +15 -0
  44. package/dist/protocol/ingress-protocol.js.map +1 -1
  45. package/dist/schemas/agents.d.ts +72 -3
  46. package/dist/schemas/agents.d.ts.map +1 -1
  47. package/dist/schemas/agents.js +10 -7
  48. package/dist/schemas/agents.js.map +1 -1
  49. package/dist/schemas/automations.d.ts +24 -0
  50. package/dist/schemas/automations.d.ts.map +1 -1
  51. package/dist/schemas/capabilities.d.ts +3 -0
  52. package/dist/schemas/capabilities.d.ts.map +1 -1
  53. package/dist/schemas/capabilities.js +4 -0
  54. package/dist/schemas/capabilities.js.map +1 -1
  55. package/dist/schemas/devices.d.ts +32 -6
  56. package/dist/schemas/devices.d.ts.map +1 -1
  57. package/dist/schemas/devices.js +8 -6
  58. package/dist/schemas/devices.js.map +1 -1
  59. package/dist/schemas/keep-warm.d.ts +68 -0
  60. package/dist/schemas/keep-warm.d.ts.map +1 -0
  61. package/dist/schemas/keep-warm.js +74 -0
  62. package/dist/schemas/keep-warm.js.map +1 -0
  63. package/dist/schemas/providers/provider-oauth.d.ts +3 -0
  64. package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
  65. package/dist/schemas/providers/provider-oauth.js +4 -0
  66. package/dist/schemas/providers/provider-oauth.js.map +1 -1
  67. package/dist/schemas/providers/usage.d.ts +6 -0
  68. package/dist/schemas/providers/usage.d.ts.map +1 -1
  69. package/dist/schemas/providers/usage.js +4 -0
  70. package/dist/schemas/providers/usage.js.map +1 -1
  71. package/dist/schemas/settings.d.ts +4 -0
  72. package/dist/schemas/settings.d.ts.map +1 -1
  73. package/dist/schemas/settings.js +23 -0
  74. package/dist/schemas/settings.js.map +1 -1
  75. package/dist/state/definition.d.ts +16 -0
  76. package/dist/state/definition.d.ts.map +1 -1
  77. package/dist/state/fix-stance.d.ts.map +1 -1
  78. package/dist/state/fix-stance.js +1 -0
  79. package/dist/state/fix-stance.js.map +1 -1
  80. package/dist/state/runtime-state.d.ts +3 -0
  81. package/dist/state/runtime-state.d.ts.map +1 -1
  82. package/dist/state/runtime-state.js +1 -0
  83. package/dist/state/runtime-state.js.map +1 -1
  84. package/dist/text/transcript-fold.d.ts +2 -0
  85. package/dist/text/transcript-fold.d.ts.map +1 -1
  86. package/dist/text/transcript-fold.js +20 -7
  87. package/dist/text/transcript-fold.js.map +1 -1
  88. package/dist/time/zone.d.ts +1 -0
  89. package/dist/time/zone.d.ts.map +1 -1
  90. package/dist/time/zone.js +5 -0
  91. package/dist/time/zone.js.map +1 -1
  92. package/package.json +16 -5
  93. package/src/contracts/agents.contract.ts +14 -2
  94. package/src/events/agent-events.ts +9 -1
  95. package/src/events/transcript.ts +14 -3
  96. package/src/front/generated/wire.ts +73 -0
  97. package/src/ids/hostnames.fixture.json +20 -0
  98. package/src/ids/hostnames.test.ts +20 -0
  99. package/src/index.ts +1 -0
  100. package/src/policy/command-run.test.ts +35 -0
  101. package/src/policy/command-run.ts +10 -5
  102. package/src/protocol/ingress-protocol.test.ts +45 -1
  103. package/src/protocol/ingress-protocol.ts +25 -4
  104. package/src/schemas/agents.ts +14 -7
  105. package/src/schemas/capabilities.ts +6 -0
  106. package/src/schemas/devices.ts +16 -9
  107. package/src/schemas/keep-warm.test.ts +68 -0
  108. package/src/schemas/keep-warm.ts +124 -0
  109. package/src/schemas/providers/provider-oauth.ts +6 -0
  110. package/src/schemas/providers/usage.ts +7 -0
  111. package/src/schemas/settings.ts +30 -0
  112. package/src/state/fix-stance.test.ts +14 -0
  113. package/src/state/fix-stance.ts +2 -0
  114. package/src/state/runtime-state.ts +4 -0
  115. package/src/text/transcript-fold.test.ts +20 -1
  116. package/src/text/transcript-fold.ts +39 -9
  117. package/src/time/zone.ts +7 -0
  118. package/src/webext/index.ts +1 -1
@@ -9,6 +9,14 @@ import { DEV_VERSION } from "../state/versions.js";
9
9
  // One sandbox's resource share as docker currently enforces it, read off the container by the machine agent.
10
10
  // `overlayRuntime` is the environment's locked demand; `hostRuntime` is the owner's addition; `privileged`/`gpu` are
11
11
  // docker's enforced truth.
12
+ // Reshape request, turned into `ic sandbox reshape` flags: absent means leave it, `null` on the two caps means back to
13
+ // the default; at least one key must be set.
14
+ export const SandboxResourcesAskFieldsSchema = z.object({
15
+ memoryGib: z.int().positive().nullable().optional(),
16
+ cpus: z.int().positive().nullable().optional(),
17
+ privileged: z.boolean().optional(),
18
+ gpu: z.boolean().optional(),
19
+ });
12
20
  export const SandboxResourcesSchema = z.object({
13
21
  // The cgroup memory ceiling in bytes; absent when docker imposes none (the hosted shape).
14
22
  memoryBytes: z.number().optional(),
@@ -18,17 +26,12 @@ export const SandboxResourcesSchema = z.object({
18
26
  gpu: z.boolean(),
19
27
  hostRuntime: z.array(z.string()),
20
28
  overlayRuntime: z.array(z.string()),
29
+ // A reshape saved for the sandbox's next restart (`ic sandbox reshape --later`) and not yet in force: the next
30
+ // update, rollback, rebuild, reshape or Restart applies it. Absent when nothing is saved.
31
+ saved: SandboxResourcesAskFieldsSchema.optional(),
21
32
  });
22
33
  export type SandboxResources = z.infer<typeof SandboxResourcesSchema>;
23
34
 
24
- // Reshape request, turned into `ic sandbox reshape` flags: absent means leave it, `null` on the two caps means back to
25
- // the default; at least one key must be set.
26
- export const SandboxResourcesAskFieldsSchema = z.object({
27
- memoryGib: z.int().positive().nullable().optional(),
28
- cpus: z.int().positive().nullable().optional(),
29
- privileged: z.boolean().optional(),
30
- gpu: z.boolean().optional(),
31
- });
32
35
  // Fields also exported separately for callers that compose them and enforce the at-least-one rule themselves.
33
36
  export const SandboxResourcesAskSchema = SandboxResourcesAskFieldsSchema.refine((ask) => Object.values(ask).some((value) => value !== undefined), {
34
37
  message: "a reshape must change at least one thing",
@@ -81,8 +84,12 @@ export const DeviceSandboxFlowSchema = z.object({
81
84
  slug: z.string().min(1),
82
85
  // Approved overlay's sha256, required only by `rebuild`; only content matching it is ever built.
83
86
  hash: z.string().optional(),
84
- // What `reshape` should change, required by it and meaningless to the rest.
87
+ // What `reshape` should change, meaningless to the rest. Absent on a reshape applies what is saved for the next
88
+ // restart now; absent with `later` forgets what is saved.
85
89
  resources: SandboxResourcesAskSchema.optional(),
90
+ // `reshape` only: save `resources` for the sandbox's next restart instead of restarting it now, replacing
91
+ // whatever was saved before (`ic sandbox reshape --later`).
92
+ later: z.boolean().optional(),
86
93
  // `runner-up` only, daemon-filled, never by the caller: the browser never holds the pairing credential.
87
94
  parentUrl: z.string().optional(),
88
95
  pair: z.string().optional().meta({ secret: true }),
@@ -0,0 +1,68 @@
1
+ import { TranscriptFold } from "../text/transcript-fold.js";
2
+ import {
3
+ changedParts,
4
+ keepWarmCap,
5
+ keepWarmDueAt,
6
+ keepWarmHorizon,
7
+ keepWarmLeadMs,
8
+ keepWarmMaxRefreshes,
9
+ keepWarmRefreshes,
10
+ keptWarmLine,
11
+ } from "./keep-warm.js";
12
+
13
+ const MINUTE = 60_000;
14
+ const HOUR = 60 * MINUTE;
15
+
16
+ // The arithmetic both the daemon and every press read, so the price shown before a press is the price the daemon keeps.
17
+ describe("keep-warm economics", () => {
18
+ test("refreshes a fifth of an entry's life early, never under a minute or over ten", () => {
19
+ expect(keepWarmLeadMs(HOUR)).toBe(10 * MINUTE);
20
+ expect(keepWarmLeadMs(5 * MINUTE)).toBe(MINUTE);
21
+ });
22
+
23
+ test("spends at most half of what a cold resume would rewrite", () => {
24
+ // A 1h rewrite costs 2x base input and a read 0.1x: nineteen reads break even, so nine.
25
+ expect(keepWarmMaxRefreshes(HOUR)).toBe(9);
26
+ // A 5m rewrite costs 1.25x: eleven reads break even, so five.
27
+ expect(keepWarmMaxRefreshes(5 * MINUTE)).toBe(5);
28
+ });
29
+
30
+ test("counts the refreshes that keep an entry alive through the deadline", () => {
31
+ const cache = { at: 0, ttlMs: HOUR };
32
+ expect(keepWarmRefreshes(cache, HOUR)).toBe(0);
33
+ expect(keepWarmRefreshes(cache, 4 * HOUR)).toBe(4);
34
+ expect(keepWarmDueAt(cache)).toBe(50 * MINUTE);
35
+ });
36
+
37
+ test("reaches no further than the horizon, or the date change when that comes first", () => {
38
+ const cache = { at: 0, ttlMs: HOUR };
39
+ expect(keepWarmHorizon(cache)).toBe(HOUR + 9 * 50 * MINUTE);
40
+ expect(keepWarmCap(cache, 3 * HOUR)).toBe(3 * HOUR);
41
+ expect(keepWarmCap({ at: 0, ttlMs: 5 * MINUTE }, undefined)).toBe(5 * MINUTE + 5 * 4 * MINUTE);
42
+ });
43
+
44
+ test("names the parts two fingerprints disagree on", () => {
45
+ const before = { hash: "a", parts: { version: "1", model: "m", day: "d1" } };
46
+ expect(changedParts(before, { hash: "b", parts: { version: "2", model: "m", day: "d2" } })).toEqual(["day", "version"]);
47
+ expect(changedParts(before, before)).toEqual([]);
48
+ });
49
+ });
50
+
51
+ describe("the receipt a kept conversation's turn leaves", () => {
52
+ test("says it picked up warm, and for how long it was kept", () => {
53
+ expect(keptWarmLine({ readTokens: 251_000, writtenTokens: 3_000, kept: { forMs: 3 * HOUR + 12 * MINUTE, refreshes: 4 } })).toBe(
54
+ "Picked up warm: 251k tokens read from the cache, kept warm for 3h 12m with 4 refreshes.",
55
+ );
56
+ });
57
+
58
+ test("says so plainly when the turn went cold anyway", () => {
59
+ expect(keptWarmLine({ readTokens: 0, writtenTokens: 250_000, kept: { forMs: 40 * MINUTE, refreshes: 1 } })).toContain("Picked up cold");
60
+ });
61
+
62
+ test("is a notice row in the transcript, and nothing for a turn nobody kept", () => {
63
+ const fold = new TranscriptFold([]);
64
+ expect(fold.apply({ kind: "prompt_cache", readTokens: 10, writtenTokens: 200_000 })).toEqual([]);
65
+ fold.apply({ kind: "prompt_cache", readTokens: 251_000, writtenTokens: 3_000, kept: { forMs: HOUR, refreshes: 1 } });
66
+ expect(fold.rows.at(-1)).toMatchObject({ role: "notice", text: expect.stringContaining("Picked up warm") });
67
+ });
68
+ });
@@ -0,0 +1,124 @@
1
+ // keep-warm: holding an idle conversation's prompt cache open by re-reading it shortly before it expires.
2
+
3
+ import { z } from "zod";
4
+
5
+ const HOUR_MS = 3_600_000;
6
+
7
+ // Anthropic's price multipliers on base input: a read costs 0.1x, a write 2x under the 1h TTL and 1.25x under 5m.
8
+ const READ_COST = 0.1;
9
+ const writeCost = (ttlMs: number): number => (ttlMs >= HOUR_MS ? 2 : 1.25);
10
+
11
+ // A cache clock as the daemon publishes it: the last request that touched the entry, and the entry's lifetime.
12
+ export interface CacheClock {
13
+ readonly at: number;
14
+ readonly ttlMs: number;
15
+ }
16
+
17
+ /** How long before expiry a refresh starts: a fifth of the entry's life, never under a minute or over ten. */
18
+ export const keepWarmLeadMs = (ttlMs: number): number => Math.min(10 * 60_000, Math.max(60_000, Math.round(ttlMs / 5)));
19
+
20
+ /** Refreshes one hold may spend: half of what a cold resume rewrites, the other half left for each refresh's own tail. */
21
+ export const keepWarmMaxRefreshes = (ttlMs: number): number => Math.floor((writeCost(ttlMs) - READ_COST) / READ_COST / 2);
22
+
23
+ /** How many refreshes keep an entry alive through `until`. */
24
+ export const keepWarmRefreshes = (cache: CacheClock, until: number): number => {
25
+ const beyond = until - (cache.at + cache.ttlMs);
26
+ return beyond <= 0 ? 0 : Math.ceil(beyond / (cache.ttlMs - keepWarmLeadMs(cache.ttlMs)));
27
+ };
28
+
29
+ /** The latest instant a hold may reach, given the refreshes it already spent: past it, refreshing costs more than the cold resume it saves. */
30
+ export const keepWarmHorizon = (cache: CacheClock, spent = 0): number =>
31
+ cache.at + cache.ttlMs + Math.max(0, keepWarmMaxRefreshes(cache.ttlMs) - spent) * (cache.ttlMs - keepWarmLeadMs(cache.ttlMs));
32
+
33
+ /** When the next refresh is due for an entry last touched at `cache.at`. */
34
+ export const keepWarmDueAt = (cache: CacheClock): number => cache.at + cache.ttlMs - keepWarmLeadMs(cache.ttlMs);
35
+
36
+ /** The furthest `until` a hold can honestly promise: the economic horizon, or the date change, whichever comes first. */
37
+ export const keepWarmCap = (cache: CacheClock, rollsAt: number | undefined, spent = 0): number =>
38
+ rollsAt === undefined ? keepWarmHorizon(cache, spent) : Math.min(keepWarmHorizon(cache, spent), rollsAt);
39
+
40
+ // Why a hold ended before anyone picked the conversation up. `elapsed` is the one ending that is not a problem.
41
+ export const KeepWarmEndSchema = z.enum(["elapsed", "allowance", "limited", "changed", "rewrote", "cold", "failed", "midnight", "moved"]);
42
+ export type KeepWarmEnd = z.infer<typeof KeepWarmEndSchema>;
43
+
44
+ export const KeepWarmSchema = z.object({
45
+ since: z.number().describe("When keeping it warm started, in milliseconds."),
46
+ until: z
47
+ .number()
48
+ .describe(
49
+ "When it stops by itself, in milliseconds: the time asked for, shortened to what the sandbox can honestly keep, which is never past the point where refreshing costs more than re-reading, nor past the date change that rewrites the prompt.",
50
+ ),
51
+ auto: z.boolean().optional().describe("Started by the sandbox-wide setting after a turn, rather than by a press on this conversation."),
52
+ refreshes: z.number().int().min(0).describe("Refreshes sent so far."),
53
+ readTokens: z
54
+ .number()
55
+ .optional()
56
+ .describe("How much the last refresh read back from the provider's cache, in tokens: the proof the cache was still there."),
57
+ ended: z
58
+ .object({
59
+ at: z.number().describe("When it stopped, in milliseconds."),
60
+ reason: KeepWarmEndSchema.describe(
61
+ "Why: `elapsed` the time asked for ran out; `allowance` the account reached the reserve kept for real work; `limited` the provider refused a refresh; `changed` what the next turn would send no longer matches the cache; `rewrote` a refresh found the cache already gone; `cold` it expired before a refresh could run; `failed` a refresh failed; `midnight` the date in the prompt changed; `moved` the conversation's session or account changed.",
62
+ ),
63
+ detail: z.string().optional().describe("The specifics, when there are any: which parts of the prompt changed, or the failure's own words."),
64
+ })
65
+ .optional()
66
+ .describe("Why keeping it warm stopped before anyone picked the conversation up. Absent while it is still being kept."),
67
+ });
68
+ export type KeepWarm = z.infer<typeof KeepWarmSchema>;
69
+
70
+ export const AgentKeepWarmSchema = z.object({
71
+ id: z.string().min(1).describe("Which conversation."),
72
+ until: z
73
+ .number()
74
+ .nullable()
75
+ .describe(
76
+ "Keep its prompt cache warm until this instant, in milliseconds; shortened to what the sandbox can honestly keep. Null stops keeping it warm.",
77
+ ),
78
+ });
79
+ export type AgentKeepWarm = z.infer<typeof AgentKeepWarmSchema>;
80
+
81
+ // What a turn's first request found in the cache, and how long it had been kept warm for it when it was.
82
+ export const PromptCacheOpeningSchema = z.object({
83
+ readTokens: z.number().describe("Tokens the turn's first request read from the provider's cache."),
84
+ writtenTokens: z.number().describe("Tokens it wrote to the cache, which is what it paid full price for."),
85
+ kept: z
86
+ .object({
87
+ forMs: z.number().describe("How long the cache had been kept warm for this turn, in milliseconds."),
88
+ refreshes: z.number().int().min(0).describe("How many refreshes that took."),
89
+ })
90
+ .optional()
91
+ .describe("Present when this turn picked up a conversation the sandbox had been keeping warm."),
92
+ });
93
+ export type PromptCacheOpening = z.infer<typeof PromptCacheOpeningSchema>;
94
+
95
+ const tokensLabel = (tokens: number): string => (tokens >= 1_000 ? `${Math.round(tokens / 1_000)}k` : String(tokens));
96
+
97
+ const spanLabel = (ms: number): string => {
98
+ const minutes = Math.max(1, Math.round(ms / 60_000));
99
+ const hours = Math.floor(minutes / 60);
100
+ return hours === 0 ? `${minutes}m` : `${hours}h ${minutes % 60}m`;
101
+ };
102
+
103
+ /** The transcript's line for a turn that picked up a conversation kept warm for it; undefined for any other turn. */
104
+ export const keptWarmLine = (opening: PromptCacheOpening): string | undefined => {
105
+ const { kept } = opening;
106
+ if (kept === undefined) {
107
+ return undefined;
108
+ }
109
+ const held = `kept warm for ${spanLabel(kept.forMs)} with ${kept.refreshes} ${kept.refreshes === 1 ? "refresh" : "refreshes"}`;
110
+ return opening.readTokens >= opening.writtenTokens
111
+ ? `Picked up warm: ${tokensLabel(opening.readTokens)} tokens read from the cache, ${held}.`
112
+ : `Picked up cold despite being ${held}: ${tokensLabel(opening.writtenTokens)} tokens sent again, because this turn's prompt no longer matched the cache.`;
113
+ };
114
+
115
+ // A turn's prefix, part by part, so two turns that should share a cache can say which part stopped matching.
116
+ export const PromptFingerprintSchema = z.object({
117
+ hash: z.string().describe("One short hash over every part."),
118
+ parts: z.record(z.string(), z.string()).describe("Each part's own short hash or value, by name."),
119
+ });
120
+ export type PromptFingerprint = z.infer<typeof PromptFingerprintSchema>;
121
+
122
+ /** Which parts differ between two fingerprints, by name; empty when they match. */
123
+ export const changedParts = (before: PromptFingerprint, after: PromptFingerprint): string[] =>
124
+ [...new Set([...Object.keys(before.parts), ...Object.keys(after.parts)])].filter((name) => before.parts[name] !== after.parts[name]).toSorted();
@@ -22,6 +22,12 @@ export const OauthAccountSchema = z.object({
22
22
  .optional()
23
23
  .describe("Its stored credential can no longer be renewed and somebody has to sign in again. Absent means healthy, or not checked yet."),
24
24
  detail: z.string().optional().describe("Why, in words a person can act on."),
25
+ seatRefusal: z
26
+ .string()
27
+ .optional()
28
+ .describe(
29
+ "Its organisation has switched it off for this harness (no seat): it still signs in and its plan limits may still read, but no turn can run on it until an admin gives access back. The provider's own sentence; cleared by the next turn that runs on it. Absent means no refusal is on file.",
30
+ ),
25
31
  usage: AccountUsageSchema.optional().describe(
26
32
  "How full its plan limits were when last measured, so a picker can show what is left before committing work to it. Absent until a reading exists, which reads as unknown rather than as nothing left.",
27
33
  ),
@@ -103,6 +103,13 @@ export const UsageTurnSchema = z.object({
103
103
  compactions: z.number().optional(),
104
104
  contextTokens: z.number().optional(),
105
105
  contextWindow: z.number().optional(),
106
+ // Absent is a turn; `keep-warm` is a cache refresh sent while the conversation sat idle, billed but not a turn.
107
+ purpose: z.enum(["keep-warm"]).optional().describe("What the row is when it is not a turn: `keep-warm` is a cache refresh sent while the conversation sat idle."),
108
+ // The first request alone: a warm resume reads nearly the whole context here, a cold one writes it.
109
+ openingCacheReadTokens: z.number().optional().describe("Tokens the turn's first request read from the provider's cache."),
110
+ openingCacheCreationTokens: z.number().optional().describe("Tokens the turn's first request wrote to the provider's cache."),
111
+ // Equal on two turns means the prefix both sent was assembled from the same parts (PromptFingerprintSchema).
112
+ promptFingerprint: z.string().optional().describe("A short hash over the parts of the prompt a cache is keyed on; a change between two turns names why the second could not reuse the first's cache."),
106
113
  // This turn's model was chosen by the Auto judge reading the conversation's opening message, not picked by hand.
107
114
  // Marked on that one turn only; a later row of the same conversation naming a different model is the user
108
115
  // overruling it, which is the escalation rate this feature has to be able to answer for.
@@ -387,6 +387,36 @@ export const SandboxSettingsSchema = z.object({
387
387
  .describe(
388
388
  "When a spent usage limit moves a turn to another account, carry the provider session (the model keeps everything, and re-reads all of it once on the other account) while the conversation's context is under this many tokens; at or above it, start a fresh session with the sandbox's measured brief instead. Zero always starts fresh.",
389
389
  ),
390
+ // Off by default: every refresh spends the account's own allowance on a conversation nobody is using yet.
391
+ keepWarm: z
392
+ .boolean()
393
+ .default(false)
394
+ .describe(
395
+ "Keep a Claude conversation's prompt cache warm after each turn a person asked for, so coming back to it hours later costs a cache read instead of re-sending everything. Each refresh re-reads the cached context at the cache price and adds nothing to the conversation. Any one conversation can be kept warm or let cool by hand either way.",
396
+ ),
397
+ keepWarmHours: z
398
+ .number()
399
+ .min(1)
400
+ .max(8)
401
+ .default(4)
402
+ .describe(
403
+ "How long an idle conversation is kept warm after its last turn, in hours. 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.",
404
+ ),
405
+ keepWarmMinTokens: z
406
+ .number()
407
+ .int()
408
+ .min(0)
409
+ .default(100_000)
410
+ .describe("Only conversations at least this large, in tokens, are kept warm by themselves: a small one is cheap to re-read anyway."),
411
+ keepWarmReserve: z
412
+ .number()
413
+ .int()
414
+ .min(0)
415
+ .max(90)
416
+ .default(15)
417
+ .describe(
418
+ "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.",
419
+ ),
390
420
  // Worth it since the container is recreated on every update or environment approval — otherwise approving a
391
421
  // Dockerfile change costs the run that asked for it.
392
422
  // What happens to breakage found after the work that caused it has left the turn: a land that turns the main tree's own
@@ -45,6 +45,20 @@ test("held work reads as a fix to review, whether the fleet called it ready or l
45
45
  expect(fixStance(agent({ status: `idle`, diff: { files: 4, insertions: 120, deletions: 8 } })).kind).toBe(`ready`);
46
46
  });
47
47
 
48
+ test.each([
49
+ ["holding work", { files: 4, insertions: 120, deletions: 8 }],
50
+ ["holding nothing yet", undefined],
51
+ ] as const)("a turn that ended waiting on its own watch, %s, is still working", (_case, diff) => {
52
+ const watches = [{ id: `watch-a1b2`, note: `CI on the pushed branch`, intervalSeconds: 60, deadlineAt: 9_000 }];
53
+ expect(fixStance(agent({ status: `idle`, watches, ...(diff === undefined ? {} : { diff }) }))).toStrictEqual({
54
+ kind: `working`,
55
+ ongoing: true,
56
+ retry: false,
57
+ label: `Agent working`,
58
+ hint: `An agent is already working on this failure. Open the conversation to watch it.`,
59
+ });
60
+ });
61
+
48
62
  // Diff present doesn't change a failed status to ready; it only earns a mention in the hint.
49
63
  test("an agent that failed after writing files still reads as failed", () => {
50
64
  const stance = fixStance(agent({ status: `error`, diff: { files: 2, insertions: 34, deletions: 6 } }));
@@ -130,6 +130,8 @@ const RULES: readonly ((agent: AgentSummary) => FixStance | undefined)[] = [
130
130
  ? needsYou("Needs you", "The fix agent has stopped and is waiting on you.")
131
131
  : undefined,
132
132
  (agent) => (IN_FLIGHT.has(agent.status) ? WORKING : undefined),
133
+ // An armed watch wakes it again by itself, so neither what it holds nor holding nothing is its last word yet.
134
+ (agent) => ((agent.watches?.length ?? 0) > 0 ? WORKING : undefined),
133
135
  (agent) => (holdingWork(agent) ? READY : undefined),
134
136
  (agent) => (agent.status === "landed" ? LANDED : undefined),
135
137
  ];
@@ -27,6 +27,10 @@ const RUNTIME_DOMAINS = [
27
27
  // The agent's Chromiums and open pages, daemon-held, minted from its own browser tool-call hooks.
28
28
  { domain: "browsers", invalidates: [["browsers"]] },
29
29
 
30
+ // A connection's status as its last probe found it: pushed when a probe behind a served answer finds another one, and
31
+ // when a browser sign-in is saved or cleared, which is what moves an account's card off "log in".
32
+ { domain: "capabilities", invalidates: [["capabilities"]] },
33
+
30
34
  // Daemon-held; changes continuously (tokens, tool uses), so it's rate-limited rather than pushed per mutation.
31
35
  { domain: "subagents", invalidates: [["subagents"]] },
32
36
 
@@ -439,7 +439,7 @@ describe("patches", () => {
439
439
  "append",
440
440
  "tool",
441
441
  "tool",
442
- "tool",
442
+ "toolThinking",
443
443
  "tool",
444
444
  "replace",
445
445
  "append",
@@ -451,6 +451,25 @@ describe("patches", () => {
451
451
  ]);
452
452
  });
453
453
 
454
+ it("send a delegation's card without its subtree or thinking, and a client keeps the ones it has", () => {
455
+ const events: AgentEvent[] = [
456
+ { kind: "tool_call", id: "task-1", name: "Agent", category: "other", status: "in_progress" },
457
+ { kind: "tool_call", id: "t2", name: "Read", category: "read", status: "in_progress", parentToolUseId: "task-1" },
458
+ { kind: "thinking", text: "in", parentToolUseId: "task-1" },
459
+ { kind: "thinking", text: "ner", parentToolUseId: "task-1" },
460
+ { kind: "tool_call_update", id: "task-1", status: "completed" },
461
+ ];
462
+ const { folded, applied, patches } = replay(openingOf("go"), events);
463
+ expect(applied).toEqual(folded);
464
+ expect(patches.filter((patch) => patch.op === "toolThinking")).toEqual([
465
+ { op: "toolThinking", index: 1, id: "task-1", text: "in" },
466
+ { op: "toolThinking", index: 1, id: "task-1", text: "ner" },
467
+ ]);
468
+ const update = patches.at(-1);
469
+ expect(update?.op === "tool" ? update.tool : undefined).toEqual({ id: "task-1", name: "Agent", category: "other", status: "completed" });
470
+ expect(applied[1]?.tools?.[0]).toMatchObject({ status: "completed", thinking: "inner", children: [{ id: "t2" }] });
471
+ });
472
+
454
473
  // The other half of the wake's delivery: a live turn takes it as a steer. It is the daemon's own words either way,
455
474
  // so it must not become a user row here, and must not leave a rewind anchor pointing at a message nobody sent.
456
475
  it("writes a watch's wake into a live turn as a notice, and never as a steer to rewind to", () => {
@@ -11,6 +11,7 @@ import {
11
11
  type TranscriptTool,
12
12
  } from "../events/transcript.js";
13
13
  import { contextTrimLine } from "../schemas/context-trim.js";
14
+ import { keptWarmLine } from "../schemas/keep-warm.js";
14
15
  import { turnedAwayCode } from "../policy/turned-away.js";
15
16
  import { mentionedPathTokens } from "./mentions.js";
16
17
  import { unspokenPromptRow } from "../events/agent-words.js";
@@ -21,6 +22,9 @@ import { unspokenPromptRow } from "../events/agent-words.js";
21
22
 
22
23
  export type TurnEnding = "settled" | "stopped";
23
24
 
25
+ // A compaction's row, live and restored from the provider's store alike.
26
+ export const COMPACTED_NOTICE = "Context compacted to free up space.";
27
+
24
28
  // Where a tool card lives: its row, and the parent card it nests under when it's a helper's own call.
25
29
  interface CardPlace {
26
30
  readonly tool: TranscriptTool;
@@ -32,6 +36,9 @@ interface CardPlace {
32
36
  const defined = <T extends object>(value: T): Partial<T> =>
33
37
  Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) as Partial<T>;
34
38
 
39
+ // A card's own fields: what a `tool` patch carries, so a delegation's growing subtree never rides one.
40
+ const ownFields = ({ children: _children, thinking: _thinking, ...own }: TranscriptTool): TranscriptTool => own;
41
+
35
42
  const cardOf = (event: Extract<AgentEvent, { kind: "tool_call" }>): TranscriptTool => ({
36
43
  id: event.id,
37
44
  name: event.name,
@@ -296,7 +303,16 @@ export class TranscriptFold {
296
303
  }
297
304
  case "usage": {
298
305
  // Lands on the last assistant bubble and closes it: a steered turn's stream can carry several turns.
299
- const { kind: _kind, account: _account, cacheReadTokens: _read, cacheCreationTokens: _written, ...usage } = event;
306
+ const {
307
+ kind: _kind,
308
+ account: _account,
309
+ cacheReadTokens: _read,
310
+ cacheCreationTokens: _written,
311
+ openingCacheReadTokens: _openingRead,
312
+ openingCacheCreationTokens: _openingWritten,
313
+ promptFingerprint: _fingerprint,
314
+ ...usage
315
+ } = event;
300
316
  const index = this.rows.findLastIndex((row) => row.role === "assistant");
301
317
  const closed = this.closeBubble();
302
318
  if (index === -1) {
@@ -318,6 +334,11 @@ export class TranscriptFold {
318
334
  case "preamble":
319
335
  // Collapses the daemon's preamble notes onto the user row; an empty note list is not a disclosure.
320
336
  return event.notes.length === 0 ? [] : this.stampOpener((row) => (row.notes = [...event.notes]));
337
+ case "prompt_cache": {
338
+ // Only a conversation the sandbox kept warm gets a row: every other turn's cache is its own business.
339
+ const line = keptWarmLine(event);
340
+ return line === undefined ? [] : this.pushRow({ role: "notice", text: line });
341
+ }
321
342
  case "context_trim":
322
343
  // A row of its own rather than a stamp on the message: what was left out is not part of what was sent,
323
344
  // and the fold beside the message only ever lists notes that actually rode.
@@ -327,7 +348,7 @@ export class TranscriptFold {
327
348
  case "landed":
328
349
  return this.pushRow(landedRow(event));
329
350
  case "compact":
330
- return this.pushRow({ role: "notice", text: `Context compacted to free up space.` });
351
+ return this.pushRow({ role: "notice", text: COMPACTED_NOTICE });
331
352
  case "error":
332
353
  // Keeps a refusal (no prose from the provider) from reading as a session that ended mid-question.
333
354
  // A refusal that ran nothing takes its message back out ahead of the notice standing in for it.
@@ -519,9 +540,8 @@ export class TranscriptFold {
519
540
  return [];
520
541
  }
521
542
  if (event.kind === "thinking") {
522
- return this.patchCard(parent, (tool) => {
523
- tool.thinking = `${tool.thinking ?? ""}${event.text}`;
524
- });
543
+ place.tool.thinking = `${place.tool.thinking ?? ""}${event.text}`;
544
+ return [{ op: "toolThinking", index: place.row, id: parent, text: event.text }];
525
545
  }
526
546
  if (event.kind === "tool_call") {
527
547
  const child = cardOf(event);
@@ -590,7 +610,7 @@ export class TranscriptFold {
590
610
  return [];
591
611
  }
592
612
  mutate(place.tool);
593
- return [{ op: "tool", index: place.row, tool: structuredClone(place.tool), ...(place.parent === undefined ? {} : { parent: place.parent }) }];
613
+ return [{ op: "tool", index: place.row, tool: structuredClone(ownFields(place.tool)), ...(place.parent === undefined ? {} : { parent: place.parent }) }];
594
614
  }
595
615
 
596
616
  private stampOpener(mutate: (row: TranscriptRow) => void): TranscriptPatch[] {
@@ -637,15 +657,25 @@ export const applyTranscriptPatch = (rows: readonly TranscriptRow[], patch: Tran
637
657
  return rows.map((row, index) => (index === patch.index ? { ...row, text: `${row.text}${patch.text}` } : row));
638
658
  case "thinking":
639
659
  return rows.map((row, index) => (index === patch.index ? { ...row, thinking: `${row.thinking ?? ""}${patch.text}` } : row));
660
+ case "toolThinking":
661
+ return rows.map((row, index) => (index === patch.index ? { ...row, tools: [...appendToolThinking(row.tools ?? [], patch.id, patch.text)] } : row));
640
662
  case "tool":
641
663
  return rows.map((row, index) => (index === patch.index ? { ...row, tools: upsertTool(row.tools ?? [], patch.tool, patch.parent) } : row));
642
664
  }
643
665
  };
644
666
 
645
- // Replaces the tool with this id anywhere in the tree, or appends it under `parent`, or at top level with no parent or
646
- // no match.
667
+ // Appends a delegated subagent's reasoning onto the card with this id, wherever it nests.
668
+ export const appendToolThinking = (tools: readonly TranscriptTool[], id: string, text: string): readonly TranscriptTool[] =>
669
+ mapTool(tools, id, (card) => ({ ...card, thinking: `${card.thinking ?? ""}${text}` }));
670
+
671
+ // Replaces the tool with this id anywhere in the tree, keeping the nested calls and thinking it already holds, or appends
672
+ // it under `parent`, or at top level with no parent or no match.
647
673
  export const upsertTool = (tools: readonly TranscriptTool[], tool: TranscriptTool, parent: string | undefined): TranscriptTool[] => {
648
- const replaced = mapTool(tools, tool.id, () => tool);
674
+ const replaced = mapTool(tools, tool.id, (current) => ({
675
+ ...tool,
676
+ ...(current.children === undefined ? {} : { children: current.children }),
677
+ ...(current.thinking === undefined ? {} : { thinking: current.thinking }),
678
+ }));
649
679
  if (replaced !== tools) {
650
680
  return [...replaced];
651
681
  }
package/src/time/zone.ts CHANGED
@@ -108,6 +108,13 @@ const offsetMinutes = (at: number, zone: Zone): number => {
108
108
  return (match[1] === "-" ? -1 : 1) * (Number(match[2]) * 60 + Number(match[3]));
109
109
  };
110
110
 
111
+ /** The instant the calendar day after the one `at` falls on begins, in a named zone: where `civilDayIn` next changes. */
112
+ export const nextDayStartIn = (at: number, zone: Zone): number => {
113
+ const [year = 0, month = 1, day = 1] = civilDayIn(at, zone).split("-").map(Number);
114
+ const midnightUtc = Date.UTC(year, month - 1, day + 1);
115
+ return midnightUtc - offsetMinutes(midnightUtc, zone) * 60_000;
116
+ };
117
+
111
118
  /** Whether two zones are showing the same wall clock right now — which is when naming one on screen would be noise. */
112
119
  export const sameClock = (a: Zone, b: Zone, at: number = Date.now()): boolean => a === b || offsetMinutes(at, a) === offsetMinutes(at, b);
113
120
 
@@ -2,7 +2,7 @@
2
2
  // `src/index.ts` reaches every schema and contract in the repo, and the extension's bundle is uploaded to a store,
3
3
  // where ~400 kB of the daemon's unrelated wire surface is both dead weight and something a reviewer has to account for.
4
4
  // Nothing here may import that barrel, or the saving is silently undone — `pnpm --filter @intentic/webext build`
5
- // prints the bundle size, and _devices/webext/README.md records what it should be.
5
+ // checks the bundle against the ceiling in _devices/webext/scripts/size-budget.mjs.
6
6
  export { webextContract } from "../contracts/webext.contract.js";
7
7
  export * from "../protocol/webext-links.js";
8
8
  export * from "../protocol/webext-protocol.js";