@intentic/sandbox-contract 1.340.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 (179) hide show
  1. package/README.md +4 -0
  2. package/dist/contracts/agent.contract.d.ts +274 -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 +87 -0
  7. package/dist/contracts/agents.contract.d.ts.map +1 -1
  8. package/dist/contracts/ci.contract.d.ts +5 -0
  9. package/dist/contracts/ci.contract.d.ts.map +1 -1
  10. package/dist/contracts/git.contract.d.ts +320 -0
  11. package/dist/contracts/git.contract.d.ts.map +1 -1
  12. package/dist/contracts/git.contract.js +28 -0
  13. package/dist/contracts/git.contract.js.map +1 -1
  14. package/dist/contracts/runner.contract.d.ts +205 -125
  15. package/dist/contracts/runner.contract.d.ts.map +1 -1
  16. package/dist/contracts/sessions.contract.d.ts +35 -0
  17. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  18. package/dist/contracts/settings.contract.d.ts +286 -6
  19. package/dist/contracts/settings.contract.d.ts.map +1 -1
  20. package/dist/contracts/system.contract.d.ts +42 -30
  21. package/dist/contracts/system.contract.d.ts.map +1 -1
  22. package/dist/contracts/workspace.contract.d.ts +9 -0
  23. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  24. package/dist/contracts/workspace.contract.js +11 -1
  25. package/dist/contracts/workspace.contract.js.map +1 -1
  26. package/dist/events/agent-events.d.ts +143 -0
  27. package/dist/events/agent-events.d.ts.map +1 -1
  28. package/dist/events/agent-events.js +3 -0
  29. package/dist/events/agent-events.js.map +1 -1
  30. package/dist/events/agent-words.d.ts +1 -0
  31. package/dist/events/agent-words.d.ts.map +1 -1
  32. package/dist/events/agent-words.js +2 -2
  33. package/dist/events/agent-words.js.map +1 -1
  34. package/dist/events/review.d.ts +70 -0
  35. package/dist/events/review.d.ts.map +1 -0
  36. package/dist/events/review.js +29 -0
  37. package/dist/events/review.js.map +1 -0
  38. package/dist/events/system-events.d.ts +8 -0
  39. package/dist/events/system-events.d.ts.map +1 -1
  40. package/dist/events/system-events.js +2 -0
  41. package/dist/events/system-events.js.map +1 -1
  42. package/dist/events/transcript.d.ts +210 -0
  43. package/dist/events/transcript.d.ts.map +1 -1
  44. package/dist/events/transcript.js +2 -0
  45. package/dist/events/transcript.js.map +1 -1
  46. package/dist/events/undelivered.d.ts +6 -0
  47. package/dist/events/undelivered.d.ts.map +1 -0
  48. package/dist/events/undelivered.js +9 -0
  49. package/dist/events/undelivered.js.map +1 -0
  50. package/dist/index.d.ts +1086 -64
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/models/agent-standing.d.ts +1 -0
  53. package/dist/models/agent-standing.d.ts.map +1 -1
  54. package/dist/models/agent-standing.js +4 -1
  55. package/dist/models/agent-standing.js.map +1 -1
  56. package/dist/models/plan-pools.d.ts.map +1 -1
  57. package/dist/models/plan-pools.js +2 -2
  58. package/dist/models/plan-pools.js.map +1 -1
  59. package/dist/netd/generated/browser-wire.d.ts +2 -0
  60. package/dist/netd/generated/browser-wire.d.ts.map +1 -1
  61. package/dist/protocol/peer-dial.d.ts +2 -1
  62. package/dist/protocol/peer-dial.d.ts.map +1 -1
  63. package/dist/protocol/peer-dial.js.map +1 -1
  64. package/dist/protocol/raw/raw-routes.d.ts +4 -0
  65. package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
  66. package/dist/protocol/raw/raw-routes.js +1 -0
  67. package/dist/protocol/raw/raw-routes.js.map +1 -1
  68. package/dist/schemas/agent.d.ts +4 -0
  69. package/dist/schemas/agent.d.ts.map +1 -1
  70. package/dist/schemas/agent.js +4 -0
  71. package/dist/schemas/agent.js.map +1 -1
  72. package/dist/schemas/agents-list.d.ts +1 -0
  73. package/dist/schemas/agents-list.d.ts.map +1 -1
  74. package/dist/schemas/agents.d.ts +3 -0
  75. package/dist/schemas/agents.d.ts.map +1 -1
  76. package/dist/schemas/ci.d.ts +16 -0
  77. package/dist/schemas/ci.d.ts.map +1 -1
  78. package/dist/schemas/ci.js +9 -0
  79. package/dist/schemas/ci.js.map +1 -1
  80. package/dist/schemas/dev-rebuild-log.d.ts +11 -0
  81. package/dist/schemas/dev-rebuild-log.d.ts.map +1 -0
  82. package/dist/schemas/dev-rebuild-log.js +30 -0
  83. package/dist/schemas/dev-rebuild-log.js.map +1 -0
  84. package/dist/schemas/devices.d.ts +1 -10
  85. package/dist/schemas/devices.d.ts.map +1 -1
  86. package/dist/schemas/devices.js +1 -29
  87. package/dist/schemas/devices.js.map +1 -1
  88. package/dist/schemas/git/index-run.d.ts +201 -0
  89. package/dist/schemas/git/index-run.d.ts.map +1 -0
  90. package/dist/schemas/git/index-run.js +48 -0
  91. package/dist/schemas/git/index-run.js.map +1 -0
  92. package/dist/schemas/keep-warm.d.ts +7 -0
  93. package/dist/schemas/keep-warm.d.ts.map +1 -1
  94. package/dist/schemas/keep-warm.js +25 -0
  95. package/dist/schemas/keep-warm.js.map +1 -1
  96. package/dist/schemas/providers/plan-limits.d.ts +29 -0
  97. package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
  98. package/dist/schemas/providers/plan-limits.js +27 -0
  99. package/dist/schemas/providers/plan-limits.js.map +1 -1
  100. package/dist/schemas/providers/usage.d.ts +4 -0
  101. package/dist/schemas/providers/usage.d.ts.map +1 -1
  102. package/dist/schemas/providers/usage.js +4 -0
  103. package/dist/schemas/providers/usage.js.map +1 -1
  104. package/dist/schemas/settings-experiments.d.ts +15 -0
  105. package/dist/schemas/settings-experiments.d.ts.map +1 -0
  106. package/dist/schemas/settings-experiments.js +52 -0
  107. package/dist/schemas/settings-experiments.js.map +1 -0
  108. package/dist/schemas/settings.d.ts +295 -13
  109. package/dist/schemas/settings.d.ts.map +1 -1
  110. package/dist/schemas/settings.js +14 -40
  111. package/dist/schemas/settings.js.map +1 -1
  112. package/dist/schemas/voice.d.ts +3 -0
  113. package/dist/schemas/voice.d.ts.map +1 -1
  114. package/dist/schemas/voice.js +29 -1
  115. package/dist/schemas/voice.js.map +1 -1
  116. package/dist/schemas/workspace/workspace-tree.d.ts +10 -0
  117. package/dist/schemas/workspace/workspace-tree.d.ts.map +1 -1
  118. package/dist/schemas/workspace/workspace-tree.js +15 -0
  119. package/dist/schemas/workspace/workspace-tree.js.map +1 -1
  120. package/dist/state/definition.d.ts +44 -12
  121. package/dist/state/definition.d.ts.map +1 -1
  122. package/dist/text/packed-formats.d.ts +2 -0
  123. package/dist/text/packed-formats.d.ts.map +1 -0
  124. package/dist/text/packed-formats.js +8 -0
  125. package/dist/text/packed-formats.js.map +1 -0
  126. package/dist/text/preview-guide.d.ts +15 -0
  127. package/dist/text/preview-guide.d.ts.map +1 -0
  128. package/dist/text/preview-guide.js +138 -0
  129. package/dist/text/preview-guide.js.map +1 -0
  130. package/dist/text/transcript-fold.d.ts +1 -1
  131. package/dist/text/transcript-fold.d.ts.map +1 -1
  132. package/dist/text/transcript-fold.js +14 -25
  133. package/dist/text/transcript-fold.js.map +1 -1
  134. package/dist/text/transcript-inline.d.ts +8 -0
  135. package/dist/text/transcript-inline.d.ts.map +1 -0
  136. package/dist/text/transcript-inline.js +28 -0
  137. package/dist/text/transcript-inline.js.map +1 -0
  138. package/dist/time/zone.d.ts +1 -0
  139. package/dist/time/zone.d.ts.map +1 -1
  140. package/dist/time/zone.js +6 -0
  141. package/dist/time/zone.js.map +1 -1
  142. package/package.json +5 -5
  143. package/src/contracts/agent.contract.ts +13 -0
  144. package/src/contracts/git.contract.ts +33 -0
  145. package/src/contracts/workspace.contract.ts +15 -0
  146. package/src/events/agent-events.ts +7 -0
  147. package/src/events/agent-words.test.ts +6 -0
  148. package/src/events/agent-words.ts +4 -2
  149. package/src/events/review.ts +44 -0
  150. package/src/events/system-events.ts +6 -0
  151. package/src/events/transcript.ts +3 -0
  152. package/src/events/undelivered.ts +15 -0
  153. package/src/models/agent-standing.ts +21 -3
  154. package/src/models/plan-pools.test.ts +12 -0
  155. package/src/models/plan-pools.ts +3 -2
  156. package/src/netd/generated/browser-wire.json +13 -0
  157. package/src/netd/generated/browser-wire.ts +1 -1
  158. package/src/protocol/peer-dial.ts +4 -2
  159. package/src/protocol/raw/raw-routes.ts +3 -0
  160. package/src/schemas/agent.ts +6 -0
  161. package/src/schemas/ci.ts +15 -0
  162. package/src/schemas/dev-rebuild-log.test.ts +1 -1
  163. package/src/schemas/dev-rebuild-log.ts +58 -0
  164. package/src/schemas/devices.ts +2 -54
  165. package/src/schemas/git/index-run.ts +80 -0
  166. package/src/schemas/keep-warm.ts +34 -0
  167. package/src/schemas/providers/plan-limits.ts +37 -0
  168. package/src/schemas/providers/usage.ts +9 -0
  169. package/src/schemas/settings-experiments.ts +80 -0
  170. package/src/schemas/settings.ts +28 -53
  171. package/src/schemas/voice.ts +40 -1
  172. package/src/schemas/workspace/workspace-tree.ts +20 -0
  173. package/src/text/packed-formats.ts +17 -0
  174. package/src/text/preview-guide.ts +159 -0
  175. package/src/text/transcript-fold.ts +20 -35
  176. package/src/text/transcript-inline.test.ts +43 -0
  177. package/src/text/transcript-inline.ts +38 -0
  178. package/src/time/zone.test.ts +18 -1
  179. package/src/time/zone.ts +12 -0
@@ -0,0 +1,159 @@
1
+ import { z } from "zod";
2
+
3
+ // What the daemon puts first in a workspace web page it serves for a preview (GET /workspace/preview/{ticket}/*), and
4
+ // what that page may say to the editor framing it. The page runs as it would on a static file server, in a frame
5
+ // sandboxed without `allow-same-origin`, so its origin is opaque: it cannot read the editor, the daemon's API or any
6
+ // storage. This script is what keeps a page written for an ordinary origin working there, and what lets the editor
7
+ // print it, show a Copy button over it, and say which of its files are missing:
8
+ // - `localStorage`, `sessionStorage` and `document.cookie` throw in an opaque origin, and a page that touches them at
9
+ // load stops dead; each is stood in for by one held in memory for the page's life.
10
+ // - A Vite build whose base is an absolute path preloads chunks from the server's root, outside the ticket; its
11
+ // `vite:preloadError` is cancelled, so the import itself, which is relative, goes ahead.
12
+ // - `new Worker(url)` refuses any script an opaque origin did not make, which is every one; a worker is started from a
13
+ // script made here that loads the real one, its own relative imports and fetches resolved against where it lives.
14
+ // - A link to another site opens in a new tab (most refuse to be framed); a link within the page's own files is
15
+ // followed in the frame, as a browser would.
16
+ // - Every file it loads from beside itself is told, so the editor redraws it when one changes, and every one that
17
+ // fails, so the editor can name it.
18
+ // - `intenticPrint` is answered with the page as it stands, addresses made absolute and canvases drawn as pictures.
19
+ export const PREVIEW_GUIDE_SCRIPT = `(() => {
20
+ const say = (message) => parent.postMessage({ intenticHtmlPreview: message }, "*");
21
+ const memory = () => {
22
+ const held = new Map();
23
+ return {
24
+ get length() { return held.size; },
25
+ key: (index) => [...held.keys()][index] ?? null,
26
+ getItem: (key) => (held.has(String(key)) ? held.get(String(key)) : null),
27
+ setItem: (key, value) => { held.set(String(key), String(value)); },
28
+ removeItem: (key) => { held.delete(String(key)); },
29
+ clear: () => { held.clear(); },
30
+ };
31
+ };
32
+ for (const name of ["localStorage", "sessionStorage"]) {
33
+ try { void window[name]; } catch { Object.defineProperty(window, name, { value: memory(), configurable: true }); }
34
+ }
35
+ try { void document.cookie; } catch {
36
+ const jar = new Map();
37
+ Object.defineProperty(document, "cookie", {
38
+ configurable: true,
39
+ get: () => [...jar].map(([name, value]) => name + "=" + value).join("; "),
40
+ set: (text) => {
41
+ const [pair, ...attributes] = String(text).split(";");
42
+ const at = pair.indexOf("=");
43
+ const name = (at < 0 ? "" : pair.slice(0, at)).trim();
44
+ const value = (at < 0 ? pair : pair.slice(at + 1)).trim();
45
+ const gone = attributes.some((attribute) => {
46
+ const [key, ...rest] = attribute.split("=");
47
+ const setting = rest.join("=").trim();
48
+ const lower = key.trim().toLowerCase();
49
+ return (lower === "max-age" && Number(setting) <= 0) || (lower === "expires" && Date.parse(setting) < Date.now());
50
+ });
51
+ if (gone) jar.delete(name); else jar.set(name, value);
52
+ },
53
+ });
54
+ }
55
+ addEventListener("vite:preloadError", (event) => event.preventDefault());
56
+ const Native = window.Worker;
57
+ if (typeof Native === "function") {
58
+ window.Worker = function Worker(script, options) {
59
+ const url = new URL(String(script), location.href);
60
+ if (!/^https?:$/.test(url.protocol)) return new Native(script, options);
61
+ const at = JSON.stringify(url.href);
62
+ const fetches = "const get = self.fetch.bind(self); self.fetch = (input, init) => get(typeof input === 'string' ? new URL(input, " + at + ").href : input, init);";
63
+ // Chromium starts no module worker from a blob an opaque origin made, but does from a data address; the
64
+ // fetch fix is a module of its own so it runs before the worker's, as static imports run in order.
65
+ if (options?.type === "module") {
66
+ const data = (code) => "data:text/javascript," + encodeURIComponent(code);
67
+ return new Native(data("import " + JSON.stringify(data(fetches)) + "; import " + at + ";"), options);
68
+ }
69
+ const body = fetches + "const load = self.importScripts.bind(self);" +
70
+ "self.importScripts = (...urls) => load(...urls.map((each) => new URL(each, " + at + ").href)); load(" + at + ");";
71
+ return new Native(URL.createObjectURL(new Blob([body], { type: "text/javascript" })), options);
72
+ };
73
+ window.Worker.prototype = Native.prototype;
74
+ }
75
+ const prefix = location.pathname.split("/").slice(0, 4).join("/") + "/";
76
+ const own = (url) => {
77
+ try {
78
+ const parsed = new URL(url, location.href);
79
+ return parsed.origin === location.origin && parsed.pathname.startsWith(prefix) ? decodeURIComponent(parsed.pathname.slice(prefix.length)) : undefined;
80
+ // allow(silent-catch): an address that does not parse or decode is not one of the page's own files.
81
+ } catch { return undefined; }
82
+ };
83
+ addEventListener("click", (event) => {
84
+ const link = event.target instanceof Element ? event.target.closest("a[href], area[href]") : null;
85
+ if (link === null || event.button !== 0 || event.defaultPrevented) return;
86
+ const href = link.href;
87
+ if (!/^https?:/i.test(href) || own(href) !== undefined) return;
88
+ event.preventDefault();
89
+ say({ href });
90
+ });
91
+ const told = new Set();
92
+ const tell = (urls) => {
93
+ const fresh = [];
94
+ for (const url of urls) {
95
+ const path = own(url);
96
+ if (path !== undefined && !told.has(path)) { told.add(path); fresh.push(path); }
97
+ }
98
+ if (fresh.length > 0) say({ loaded: fresh });
99
+ };
100
+ tell([location.href]);
101
+ new PerformanceObserver((list) => tell(list.getEntries().map((entry) => entry.name))).observe({ type: "resource", buffered: true });
102
+ addEventListener("error", (event) => {
103
+ const element = event.target;
104
+ if (!(element instanceof Element)) return;
105
+ const url = element.currentSrc || element.src || element.href;
106
+ const path = typeof url === "string" ? own(url) : undefined;
107
+ if (path !== undefined) say({ missing: path });
108
+ }, true);
109
+ let moved = 0;
110
+ addEventListener("pointermove", (event) => {
111
+ if (event.pointerType === "touch" || event.timeStamp - moved < 150) return;
112
+ moved = event.timeStamp;
113
+ say({ pointer: true });
114
+ }, true);
115
+ addEventListener("pointerout", (event) => {
116
+ if (event.relatedTarget !== null || event.pointerType === "touch") return;
117
+ moved = 0;
118
+ say({ pointer: false });
119
+ }, true);
120
+ const snapshot = () => {
121
+ const live = [...document.querySelectorAll("canvas")];
122
+ const copy = document.documentElement.cloneNode(true);
123
+ [...copy.querySelectorAll("canvas")].forEach((canvas, index) => {
124
+ try {
125
+ const picture = document.createElement("img");
126
+ picture.src = live[index].toDataURL();
127
+ picture.style.cssText = canvas.style.cssText;
128
+ picture.width = live[index].clientWidth || live[index].width;
129
+ canvas.replaceWith(picture);
130
+ // allow(silent-catch): a canvas drawn from another origin cannot be read, and prints as the page left it.
131
+ } catch {}
132
+ });
133
+ for (const element of copy.querySelectorAll("[src], [href], [poster]")) {
134
+ for (const name of ["src", "href", "poster"]) {
135
+ const value = element.getAttribute(name);
136
+ if (value !== null && !/^(?:data|blob|javascript|mailto):/i.test(value) && URL.canParse(value, document.baseURI)) {
137
+ element.setAttribute(name, new URL(value, document.baseURI).href);
138
+ }
139
+ }
140
+ }
141
+ return copy.outerHTML;
142
+ };
143
+ addEventListener("message", (event) => {
144
+ if (event.source === parent && event.data?.intenticPrint === true) parent.postMessage({ intenticPrintHtml: snapshot() }, "*");
145
+ });
146
+ })();`;
147
+
148
+ // What a served page may tell the editor, parsed where it arrives since the page's own scripts can post anything: an
149
+ // internet address to open in a tab, whether a mouse is over it, the files it loaded from beside itself, or one that
150
+ // failed. Paths are relative to the folder the ticket serves (WorkspacePreviewTicketSchema `root`).
151
+ export const PreviewGuideSayingSchema = z.object({
152
+ intenticHtmlPreview: z.union([
153
+ z.object({ href: z.string().regex(/^https?:\/\//i) }),
154
+ z.object({ pointer: z.boolean() }),
155
+ z.object({ loaded: z.array(z.string()).max(1000) }),
156
+ z.object({ missing: z.string() }),
157
+ ]),
158
+ });
159
+ export type PreviewGuideSaying = z.infer<typeof PreviewGuideSayingSchema>["intenticHtmlPreview"];
@@ -1,6 +1,6 @@
1
1
  import { cancelledRequests, settledRequests } from "../policy/request-status.js";
2
2
  import type { AgentEvent } from "../events/agent-events.js";
3
- import type { Page } from "../events/requests.js";
3
+ import { drawInline, type InlineEvent, supersedeEarlier } from "./transcript-inline.js";
4
4
  import {
5
5
  REQUEST_FIELDS,
6
6
  holdsRequest,
@@ -16,6 +16,7 @@ import { keptWarmLine, keptWarmNotice } from "../schemas/keep-warm.js";
16
16
  import { handedOffLine, handedOffNotice } from "../schemas/providers/handoff.js";
17
17
  import { isLandConflict } from "../events/land-conflict.js";
18
18
  import { turnedAwayCode } from "../policy/turned-away.js";
19
+ import { undeliveredRow } from "../events/undelivered.js";
19
20
  import { mentionedPathTokens } from "./mentions.js";
20
21
  import { unspokenPromptRow } from "../events/agent-words.js";
21
22
  import { needRowText } from "../events/need-wake.js";
@@ -197,15 +198,6 @@ const landedRow = (event: Extract<AgentEvent, { kind: "landed" }>): TranscriptRo
197
198
  const failureOf = (event: Extract<AgentEvent, { kind: "error" }>): { message: string; error?: string } =>
198
199
  event.code === undefined ? { message: event.message } : { message: event.message, error: event.code };
199
200
 
200
- // The clause after a refusal that ran nothing. A turn somebody typed is held in its conversation's queue for another
201
- // press; a turn that started itself — an automation, a loop, a watch wake — has no typed message and nobody watching,
202
- // so promising a resend there names a composer the reader is not looking at. Neither version promises a retry: whether
203
- // one comes is the scheduler's decision, not this row's to state.
204
- const undelivered = (unattended: boolean): string =>
205
- unattended
206
- ? `Nothing ran, and nothing is held: this run started on its own, so there is no message waiting to be sent again.`
207
- : `Your message was not delivered: it is held for you to send again.`;
208
-
209
201
  // The press a memory hold offers: the raise rides along only when the hold carried a ceiling to size it by, since a
210
202
  // stall names none and raising one would not clear it.
211
203
  const memoryPress = (event: Extract<AgentEvent, { kind: "error" }>): "sendAnyway" | "sandboxMemory" =>
@@ -230,11 +222,7 @@ const heldRow = (event: Extract<AgentEvent, { kind: "error" }>): TranscriptRow =
230
222
  };
231
223
  }
232
224
  if (!memory || unattended) {
233
- return {
234
- role: "notice",
235
- text: `${event.message} ${undelivered(unattended)}`,
236
- noticeCode: noticeCode({ code: "undelivered", params: { ...said, unattended: unattended || undefined } }),
237
- };
225
+ return undeliveredRow(said, unattended);
238
226
  }
239
227
  return {
240
228
  role: "notice",
@@ -462,12 +450,9 @@ export class TranscriptFold {
462
450
  return this.steered(event);
463
451
  case "checkpoint":
464
452
  // Anchors the pre-turn snapshot id and this turn's transcript position on its user row, for rewind.
465
- return this.stampOpener((row) => {
466
- row.checkpointId = event.id;
467
- if (event.index !== undefined) {
468
- row.rewindIndex = event.index;
469
- }
470
- });
453
+ return this.stampOpener((row) =>
454
+ Object.assign(row, { checkpointId: event.id }, event.index === undefined ? {} : { rewindIndex: event.index }),
455
+ );
471
456
  case "preamble":
472
457
  // Collapses the daemon's preamble notes onto the user row; an empty note list is not a disclosure.
473
458
  return event.notes.length === 0 ? [] : this.stampOpener((row) => (row.notes = [...event.notes]));
@@ -484,7 +469,7 @@ export class TranscriptFold {
484
469
  case "handoff":
485
470
  return this.pushRow({ role: "notice", text: handedOffLine(event), noticeCode: noticeCode(handedOffNotice(event)) });
486
471
  case "worktree":
487
- return event.sync === undefined ? [] : this.synced(event.sync);
472
+ return this.synced(event.sync);
488
473
  case "landed":
489
474
  return this.pushRow(landedRow(event));
490
475
  case "compact":
@@ -553,7 +538,8 @@ export class TranscriptFold {
553
538
  case "credential_offer":
554
539
  return this.park(event.requestId, { credentialOffer: { requestId: event.requestId, offer: event.offer, status: "pending" } });
555
540
  case "page":
556
- return this.showPage(event.page);
541
+ case "review":
542
+ return this.showInline(event);
557
543
  case "page_ask":
558
544
  return this.park(event.requestId, { pageAsk: { requestId: event.requestId, page: event.page, status: "pending" } });
559
545
  case "need":
@@ -608,20 +594,16 @@ export class TranscriptFold {
608
594
  }
609
595
 
610
596
  /**
611
- * A page drawn on the bubble whose call showed it, which it then closes, as a card does: the prose after it is the
612
- * next row. A redraw of a page already shown folds the earlier row to a line rather than drawing two copies.
597
+ * Drawn on the bubble whose call showed it (a page, a review's findings), which it then closes, as a card does: the
598
+ * prose after it is the next row. A redraw of one already shown folds the earlier row to a line rather than drawing
599
+ * two copies (transcript-inline.ts).
613
600
  */
614
- private showPage(page: Page): TranscriptPatch[] {
615
- const patches: TranscriptPatch[] = [];
616
- const earlier = this.rows.findLastIndex((row) => row.page !== undefined && row.page.id === page.id && row.page.superseded !== true);
617
- if (earlier !== -1) {
618
- this.rows[earlier]!.page = { ...this.rows[earlier]!.page!, superseded: true };
619
- patches.push(this.replace(earlier));
620
- }
601
+ private showInline(event: InlineEvent): TranscriptPatch[] {
602
+ const earlier = supersedeEarlier(this.rows, event);
621
603
  const [index, opened] = this.open();
622
- this.rows[index]!.page = page;
604
+ drawInline(this.rows[index]!, event);
623
605
  this.bubble = undefined;
624
- return [...patches, ...opened, this.replace(index)];
606
+ return [...(earlier === undefined ? [] : [this.replace(earlier)]), ...opened, this.replace(index)];
625
607
  }
626
608
 
627
609
  /** A message pushed into a running turn; only a person's own message is a rewind anchor. */
@@ -910,7 +892,10 @@ export class TranscriptFold {
910
892
 
911
893
  // The rebase notice, less what a resolve turn was sent to fix: that turn opens because the branch couldn't rebase, so
912
894
  // saying so again one row under its own errand only repeats it. What did move is still told.
913
- private synced(sync: { commits: number; blocked: readonly string[] }): TranscriptPatch[] {
895
+ private synced(sync: { commits: number; blocked: readonly string[] } | undefined): TranscriptPatch[] {
896
+ if (sync === undefined) {
897
+ return [];
898
+ }
914
899
  const opener = this.opener === undefined ? undefined : this.rows[this.opener];
915
900
  const told = opener !== undefined && isLandConflict(opener.text) ? { ...sync, blocked: [] } : sync;
916
901
  const text = syncLine(told);
@@ -0,0 +1,43 @@
1
+ import type { AgentEvent } from "../events/agent-events.js";
2
+ import type { Page } from "../events/requests.js";
3
+ import type { ReviewFindings } from "../events/review.js";
4
+ import { foldTurn, userRow } from "./transcript-fold.js";
5
+
6
+ // path-literals: content, a page's stored path and a finding's file as transcript rows carry them
7
+ const PAGE: Page = { id: "p1", title: "Q3 revenue", path: ".intentic/records/artifacts/pages/c1/p1.r0.html" };
8
+ const REVIEW: ReviewFindings = {
9
+ id: "review-1",
10
+ source: "claude",
11
+ level: "high",
12
+ findings: [{ file: "src/parse.ts", line: 12, summary: "Off-by-one drops the last token.", verdict: "CONFIRMED" }],
13
+ };
14
+
15
+ const fold = (events: readonly AgentEvent[]) => foldTurn([userRow("/code-review high", 1, [])], events).slice(1);
16
+
17
+ describe("a review in the fold", () => {
18
+ it("draws the findings on the bubble that reported them, and starts the next words below", () => {
19
+ const rows = fold([
20
+ { kind: "tool_call", id: "t1", name: "ReportFindings", category: "other", status: "in_progress" },
21
+ { kind: "review", review: REVIEW },
22
+ { kind: "delta", text: "One finding." },
23
+ ]);
24
+ expect(rows).toHaveLength(2);
25
+ expect(rows[0]?.review).toEqual(REVIEW);
26
+ expect(rows[1]).toEqual({ role: "assistant", text: "One finding." });
27
+ });
28
+
29
+ // The skill re-reports the same findings once it has fixed them: the card is redrawn, not drawn twice.
30
+ it("folds the earlier card away when the same review is re-reported with its outcomes", () => {
31
+ const fixed: ReviewFindings = { ...REVIEW, findings: REVIEW.findings.map((finding) => ({ ...finding, outcome: "fixed" as const })) };
32
+ const rows = fold([{ kind: "review", review: REVIEW }, { kind: "delta", text: "Fixing." }, { kind: "text_end" }, { kind: "review", review: fixed }]);
33
+ expect(rows[0]?.review?.superseded).toBe(true);
34
+ expect(rows.at(-1)?.review).toEqual(fixed);
35
+ });
36
+
37
+ // A page and a review share the redraw rule by id, never each other's rows.
38
+ it("keeps a page and a review that share an id apart", () => {
39
+ const rows = fold([{ kind: "page", page: PAGE }, { kind: "review", review: { ...REVIEW, id: PAGE.id } }]);
40
+ expect(rows[0]?.page?.superseded).toBeUndefined();
41
+ expect(rows[1]?.review?.id).toBe(PAGE.id);
42
+ });
43
+ });
@@ -0,0 +1,38 @@
1
+ import type { AgentEvent } from "../events/agent-events.js";
2
+ import type { TranscriptRow } from "../events/transcript.js";
3
+
4
+ // What the chat draws inline, on the bubble that showed it, rather than as a card that waits for an answer: a page the
5
+ // agent showed (show_page) and a code review's findings. Each has an id, and one shown again under its id is a redraw
6
+ // (a page redrawn, a review re-reported with its fixes applied), so the earlier row folds to a line.
7
+
8
+ export type InlineEvent = Extract<AgentEvent, { kind: "page" | "review" }>;
9
+
10
+ const drawnOf = (row: TranscriptRow, kind: InlineEvent["kind"]): { readonly id: string; readonly superseded?: boolean | undefined } | undefined =>
11
+ kind === "page" ? row.page : row.review;
12
+
13
+ // The latest row still drawing this one in full, folded now that its redraw stands below; undefined when none does.
14
+ export const supersedeEarlier = (rows: TranscriptRow[], event: InlineEvent): number | undefined => {
15
+ const id = event.kind === "page" ? event.page.id : event.review.id;
16
+ const index = rows.findLastIndex((row) => {
17
+ const drawn = drawnOf(row, event.kind);
18
+ return drawn !== undefined && drawn.id === id && drawn.superseded !== true;
19
+ });
20
+ const row = rows[index];
21
+ if (row === undefined) {
22
+ return undefined;
23
+ }
24
+ if (event.kind === "page" && row.page !== undefined) {
25
+ row.page = { ...row.page, superseded: true };
26
+ } else if (event.kind === "review" && row.review !== undefined) {
27
+ row.review = { ...row.review, superseded: true };
28
+ }
29
+ return index;
30
+ };
31
+
32
+ export const drawInline = (row: TranscriptRow, event: InlineEvent): void => {
33
+ if (event.kind === "page") {
34
+ row.page = event.page;
35
+ } else {
36
+ row.review = event.review;
37
+ }
38
+ };
@@ -1,4 +1,4 @@
1
- import { asCivilDay, asZone, civilDayIn, cronOptions, isZone, sameClock, utcDayOf, UTC, zoneLabel } from "./zone.js";
1
+ import { asCivilDay, asZone, civilDayIn, cronOptions, isZone, sameClock, utcDayOf, UTC, wallClockInstant, zoneLabel } from "./zone.js";
2
2
  import type { Zone } from "./zone.js";
3
3
 
4
4
  // The distinctions this module exists to keep, each of which was a live bug before it: a cron evaluated in the
@@ -87,3 +87,20 @@ describe(`cronOptions`, () => {
87
87
  expect(cronOptions(WARSAW)).toEqual({ timezone: `Europe/Warsaw` });
88
88
  });
89
89
  });
90
+
91
+ /* A time of day a program on some clock wrote ("try again at 5:46 PM") is an instant only with that clock's zone. */
92
+ describe(`wallClockInstant`, () => {
93
+ test(`reads the same wall time as a different instant on each clock`, () => {
94
+ expect(wallClockInstant(asCivilDay(`2026-09-20`)!, 22, 43, UTC)).toBe(Date.UTC(2026, 8, 20, 22, 43));
95
+ expect(wallClockInstant(asCivilDay(`2026-09-20`)!, 22, 43, WARSAW)).toBe(SEPTEMBER);
96
+ expect(wallClockInstant(asCivilDay(`2026-12-20`)!, 15, 43, NEW_YORK)).toBe(DECEMBER);
97
+ });
98
+
99
+ // Warsaw changes its clocks at 01:00 UTC: on 2026-03-29 from +1 to +2, on 2026-10-25 from +2 to +1. An hour read just
100
+ // before the change sits on the other side of it from its naive guess, whose offset alone would be an hour out.
101
+ test(`takes the offset in force at the time itself, not at the naive guess, across a changeover`, () => {
102
+ expect(wallClockInstant(asCivilDay(`2026-03-29`)!, 1, 30, WARSAW)).toBe(Date.UTC(2026, 2, 29, 0, 30));
103
+ expect(wallClockInstant(asCivilDay(`2026-10-25`)!, 1, 30, WARSAW)).toBe(Date.UTC(2026, 9, 24, 23, 30));
104
+ expect(wallClockInstant(asCivilDay(`2026-10-25`)!, 9, 0, WARSAW)).toBe(Date.UTC(2026, 9, 25, 8, 0));
105
+ });
106
+ });
package/src/time/zone.ts CHANGED
@@ -115,6 +115,18 @@ export const nextDayStartIn = (at: number, zone: Zone): number => {
115
115
  return midnightUtc - offsetMinutes(midnightUtc, zone) * 60_000;
116
116
  };
117
117
 
118
+ /**
119
+ * The instant a named clock reads `hours:minutes` on a calendar day: what a sentence like "try again at 5:46 PM" means,
120
+ * written by a program on that clock. The offset is taken again at the first answer, since across a summer-time
121
+ * changeover the offset at the naive guess is not the one in force at the time itself.
122
+ */
123
+ export const wallClockInstant = (day: CivilDay, hours: number, minutes: number, zone: Zone): number => {
124
+ const [year = 0, month = 1, date = 1] = day.split("-").map(Number);
125
+ const naive = Date.UTC(year, month - 1, date, hours, minutes);
126
+ const guess = naive - offsetMinutes(naive, zone) * 60_000;
127
+ return naive - offsetMinutes(guess, zone) * 60_000;
128
+ };
129
+
118
130
  /** Whether two zones are showing the same wall clock right now — which is when naming one on screen would be noise. */
119
131
  export const sameClock = (a: Zone, b: Zone, at: number = Date.now()): boolean => a === b || offsetMinutes(at, a) === offsetMinutes(at, b);
120
132