@intentic/sandbox-contract 1.303.0 → 1.305.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 (333) hide show
  1. package/README.md +5 -3
  2. package/dist/contracts/agent.contract.d.ts +195 -41
  3. package/dist/contracts/agent.contract.d.ts.map +1 -1
  4. package/dist/contracts/agent.contract.js +8 -8
  5. package/dist/contracts/agent.contract.js.map +1 -1
  6. package/dist/contracts/agents.contract.d.ts +217 -3
  7. package/dist/contracts/agents.contract.d.ts.map +1 -1
  8. package/dist/contracts/agents.contract.js +30 -2
  9. package/dist/contracts/agents.contract.js.map +1 -1
  10. package/dist/contracts/capabilities.contract.d.ts +18 -5
  11. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  12. package/dist/contracts/capabilities.contract.js +3 -3
  13. package/dist/contracts/capabilities.contract.js.map +1 -1
  14. package/dist/contracts/{host.contract.d.ts → device.contract.d.ts} +75 -2
  15. package/dist/contracts/device.contract.d.ts.map +1 -0
  16. package/dist/contracts/device.contract.js +17 -0
  17. package/dist/contracts/device.contract.js.map +1 -0
  18. package/dist/contracts/extensions.contract.d.ts +2 -2
  19. package/dist/contracts/git.contract.d.ts +24 -0
  20. package/dist/contracts/git.contract.d.ts.map +1 -1
  21. package/dist/contracts/panels.contract.d.ts +1 -1
  22. package/dist/contracts/personas.contract.d.ts +0 -9
  23. package/dist/contracts/personas.contract.d.ts.map +1 -1
  24. package/dist/contracts/personas.contract.js +1 -10
  25. package/dist/contracts/personas.contract.js.map +1 -1
  26. package/dist/contracts/runner.contract.d.ts +138 -98
  27. package/dist/contracts/runner.contract.d.ts.map +1 -1
  28. package/dist/contracts/runner.contract.js +1 -0
  29. package/dist/contracts/runner.contract.js.map +1 -1
  30. package/dist/contracts/secrets.contract.js +2 -2
  31. package/dist/contracts/secrets.contract.js.map +1 -1
  32. package/dist/contracts/sessions.contract.d.ts +23 -3
  33. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  34. package/dist/contracts/settings.contract.d.ts +0 -23
  35. package/dist/contracts/settings.contract.d.ts.map +1 -1
  36. package/dist/contracts/system.contract.d.ts +109 -8
  37. package/dist/contracts/system.contract.d.ts.map +1 -1
  38. package/dist/contracts/system.contract.js +11 -2
  39. package/dist/contracts/system.contract.js.map +1 -1
  40. package/dist/events/agent-events.d.ts +85 -10
  41. package/dist/events/agent-events.d.ts.map +1 -1
  42. package/dist/events/agent-events.js +26 -10
  43. package/dist/events/agent-events.js.map +1 -1
  44. package/dist/events/agent-words.d.ts +20 -0
  45. package/dist/events/agent-words.d.ts.map +1 -0
  46. package/dist/events/agent-words.js +82 -0
  47. package/dist/events/agent-words.js.map +1 -0
  48. package/dist/events/{cards.d.ts → requests.d.ts} +18 -18
  49. package/dist/events/requests.d.ts.map +1 -0
  50. package/dist/events/{cards.js → requests.js} +26 -26
  51. package/dist/events/requests.js.map +1 -0
  52. package/dist/events/resume.d.ts +1 -0
  53. package/dist/events/resume.d.ts.map +1 -1
  54. package/dist/events/resume.js +2 -0
  55. package/dist/events/resume.js.map +1 -1
  56. package/dist/events/system-events.d.ts +21 -3
  57. package/dist/events/system-events.d.ts.map +1 -1
  58. package/dist/events/transcript.d.ts +165 -25
  59. package/dist/events/transcript.d.ts.map +1 -1
  60. package/dist/events/transcript.js +34 -15
  61. package/dist/events/transcript.js.map +1 -1
  62. package/dist/events/watch-wake.d.ts.map +1 -1
  63. package/dist/events/watch-wake.js +4 -0
  64. package/dist/events/watch-wake.js.map +1 -1
  65. package/dist/index.d.ts +629 -129
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +10 -4
  68. package/dist/index.js.map +1 -1
  69. package/dist/models/model-offer.d.ts +2 -1
  70. package/dist/models/model-offer.d.ts.map +1 -1
  71. package/dist/models/model-offer.js +5 -4
  72. package/dist/models/model-offer.js.map +1 -1
  73. package/dist/models/model-roles.d.ts +2 -9
  74. package/dist/models/model-roles.d.ts.map +1 -1
  75. package/dist/models/model-roles.js +2 -9
  76. package/dist/models/model-roles.js.map +1 -1
  77. package/dist/policy/command-classes.d.ts +1 -0
  78. package/dist/policy/command-classes.d.ts.map +1 -1
  79. package/dist/policy/command-classes.js +8 -0
  80. package/dist/policy/command-classes.js.map +1 -1
  81. package/dist/policy/persona-home.d.ts +10 -0
  82. package/dist/policy/persona-home.d.ts.map +1 -0
  83. package/dist/policy/persona-home.js +11 -0
  84. package/dist/policy/persona-home.js.map +1 -0
  85. package/dist/policy/request-status.d.ts +5 -0
  86. package/dist/policy/request-status.d.ts.map +1 -0
  87. package/dist/policy/{card-status.js → request-status.js} +3 -3
  88. package/dist/policy/request-status.js.map +1 -0
  89. package/dist/policy/safety-policy.d.ts +1 -1
  90. package/dist/policy/safety-policy.d.ts.map +1 -1
  91. package/dist/policy/safety-policy.js +1 -0
  92. package/dist/policy/safety-policy.js.map +1 -1
  93. package/dist/policy/turned-away.d.ts +2 -0
  94. package/dist/policy/turned-away.d.ts.map +1 -0
  95. package/dist/policy/turned-away.js +13 -0
  96. package/dist/policy/turned-away.js.map +1 -0
  97. package/dist/protocol/peer-dial.d.ts +9 -0
  98. package/dist/protocol/peer-dial.d.ts.map +1 -1
  99. package/dist/protocol/peer-dial.js +12 -4
  100. package/dist/protocol/peer-dial.js.map +1 -1
  101. package/dist/protocol/peer-mcp-server.d.ts +1 -0
  102. package/dist/protocol/peer-mcp-server.d.ts.map +1 -1
  103. package/dist/protocol/peer-mcp-server.js +14 -1
  104. package/dist/protocol/peer-mcp-server.js.map +1 -1
  105. package/dist/schemas/agent.d.ts +2 -1
  106. package/dist/schemas/agent.d.ts.map +1 -1
  107. package/dist/schemas/agent.js +7 -0
  108. package/dist/schemas/agent.js.map +1 -1
  109. package/dist/schemas/agents.d.ts +29 -0
  110. package/dist/schemas/agents.d.ts.map +1 -1
  111. package/dist/schemas/agents.js +18 -0
  112. package/dist/schemas/agents.js.map +1 -1
  113. package/dist/schemas/automations.d.ts +9 -0
  114. package/dist/schemas/automations.d.ts.map +1 -1
  115. package/dist/schemas/capabilities.d.ts +25 -12
  116. package/dist/schemas/capabilities.d.ts.map +1 -1
  117. package/dist/schemas/capabilities.js +11 -6
  118. package/dist/schemas/capabilities.js.map +1 -1
  119. package/dist/schemas/chat-route.d.ts +49 -0
  120. package/dist/schemas/chat-route.d.ts.map +1 -0
  121. package/dist/schemas/{model-route.js → chat-route.js} +17 -5
  122. package/dist/schemas/chat-route.js.map +1 -0
  123. package/dist/schemas/context-trim.d.ts +9 -0
  124. package/dist/schemas/context-trim.d.ts.map +1 -0
  125. package/dist/schemas/context-trim.js +18 -0
  126. package/dist/schemas/context-trim.js.map +1 -0
  127. package/dist/schemas/devices.d.ts +21 -8
  128. package/dist/schemas/devices.d.ts.map +1 -1
  129. package/dist/schemas/devices.js +8 -9
  130. package/dist/schemas/devices.js.map +1 -1
  131. package/dist/schemas/extension-updates.d.ts +4 -4
  132. package/dist/schemas/extension-updates.js +1 -1
  133. package/dist/schemas/extension-updates.js.map +1 -1
  134. package/dist/schemas/git/git.d.ts +83 -0
  135. package/dist/schemas/git/git.d.ts.map +1 -1
  136. package/dist/schemas/git/git.js +40 -0
  137. package/dist/schemas/git/git.js.map +1 -1
  138. package/dist/schemas/hosts.d.ts +36 -5
  139. package/dist/schemas/hosts.d.ts.map +1 -1
  140. package/dist/schemas/hosts.js +7 -5
  141. package/dist/schemas/hosts.js.map +1 -1
  142. package/dist/schemas/metrics.d.ts +108 -0
  143. package/dist/schemas/metrics.d.ts.map +1 -0
  144. package/dist/schemas/metrics.js +77 -0
  145. package/dist/schemas/metrics.js.map +1 -0
  146. package/dist/schemas/panels.d.ts +2 -2
  147. package/dist/schemas/panels.js +1 -1
  148. package/dist/schemas/panels.js.map +1 -1
  149. package/dist/schemas/personas.d.ts +1 -13
  150. package/dist/schemas/personas.d.ts.map +1 -1
  151. package/dist/schemas/personas.js +3 -20
  152. package/dist/schemas/personas.js.map +1 -1
  153. package/dist/schemas/providers/plan-limits.d.ts +1 -0
  154. package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
  155. package/dist/schemas/providers/plan-limits.js +6 -0
  156. package/dist/schemas/providers/plan-limits.js.map +1 -1
  157. package/dist/schemas/secrets.d.ts +1 -1
  158. package/dist/schemas/secrets.d.ts.map +1 -1
  159. package/dist/schemas/secrets.js +1 -1
  160. package/dist/schemas/secrets.js.map +1 -1
  161. package/dist/schemas/settings.d.ts +0 -42
  162. package/dist/schemas/settings.d.ts.map +1 -1
  163. package/dist/schemas/settings.js +1 -16
  164. package/dist/schemas/settings.js.map +1 -1
  165. package/dist/schemas/shared.d.ts +2 -2
  166. package/dist/schemas/shared.d.ts.map +1 -1
  167. package/dist/schemas/shared.js +3 -3
  168. package/dist/schemas/shared.js.map +1 -1
  169. package/dist/schemas/system-prompt.d.ts +104 -0
  170. package/dist/schemas/system-prompt.d.ts.map +1 -0
  171. package/dist/schemas/system-prompt.js +25 -0
  172. package/dist/schemas/system-prompt.js.map +1 -0
  173. package/dist/schemas/workflows.d.ts +2 -2
  174. package/dist/schemas/workflows.d.ts.map +1 -1
  175. package/dist/schemas/workflows.js +2 -2
  176. package/dist/schemas/workflows.js.map +1 -1
  177. package/dist/state/definition.d.ts +18 -10
  178. package/dist/state/definition.d.ts.map +1 -1
  179. package/dist/state/fix-attempt-plan.d.ts +4 -0
  180. package/dist/state/fix-attempt-plan.d.ts.map +1 -1
  181. package/dist/state/fix-attempt-plan.js +3 -2
  182. package/dist/state/fix-attempt-plan.js.map +1 -1
  183. package/dist/state/fix-stance.d.ts +1 -0
  184. package/dist/state/fix-stance.d.ts.map +1 -1
  185. package/dist/state/fix-stance.js +11 -0
  186. package/dist/state/fix-stance.js.map +1 -1
  187. package/dist/state/history-state.d.ts.map +1 -1
  188. package/dist/state/history-state.js +8 -1
  189. package/dist/state/history-state.js.map +1 -1
  190. package/dist/state/runtime-state.d.ts +0 -1
  191. package/dist/state/runtime-state.d.ts.map +1 -1
  192. package/dist/state/runtime-state.js +0 -1
  193. package/dist/state/runtime-state.js.map +1 -1
  194. package/dist/state/workspace-state.d.ts +4 -4
  195. package/dist/state/workspace-state.d.ts.map +1 -1
  196. package/dist/state/workspace-state.js +4 -4
  197. package/dist/state/workspace-state.js.map +1 -1
  198. package/dist/text/documents.d.ts +2 -2
  199. package/dist/text/documents.d.ts.map +1 -1
  200. package/dist/text/documents.js.map +1 -1
  201. package/dist/text/mentions.d.ts +1 -0
  202. package/dist/text/mentions.d.ts.map +1 -1
  203. package/dist/text/mentions.js +5 -1
  204. package/dist/text/mentions.js.map +1 -1
  205. package/dist/text/transcript-fold.d.ts +8 -3
  206. package/dist/text/transcript-fold.d.ts.map +1 -1
  207. package/dist/text/transcript-fold.js +83 -28
  208. package/dist/text/transcript-fold.js.map +1 -1
  209. package/package.json +9 -9
  210. package/src/chores/chores.test.ts +1 -1
  211. package/src/chores/digest.test.ts +1 -1
  212. package/src/chores/probes.test.ts +5 -2
  213. package/src/chores/stack.test.ts +1 -1
  214. package/src/chores/verdict.test.ts +6 -5
  215. package/src/contracts/agent.contract.ts +10 -9
  216. package/src/contracts/agents.contract.ts +35 -2
  217. package/src/contracts/capabilities.contract.ts +3 -3
  218. package/src/contracts/{host.contract.ts → device.contract.ts} +8 -6
  219. package/src/contracts/personas.contract.ts +1 -13
  220. package/src/contracts/runner.contract.ts +3 -2
  221. package/src/contracts/secrets.contract.ts +2 -2
  222. package/src/contracts/system.contract.ts +13 -2
  223. package/src/contracts/webext.contract.ts +1 -1
  224. package/src/events/agent-events.ts +37 -14
  225. package/src/events/agent-words.test.ts +68 -0
  226. package/src/events/agent-words.ts +124 -0
  227. package/src/events/{cards.ts → requests.ts} +44 -44
  228. package/src/events/resume.test.ts +8 -1
  229. package/src/events/resume.ts +4 -0
  230. package/src/events/transcript.ts +51 -22
  231. package/src/events/watch-wake.test.ts +3 -2
  232. package/src/events/watch-wake.ts +4 -0
  233. package/src/ids/conversation-ids.test.ts +3 -3
  234. package/src/ids/hostnames.test.ts +1 -1
  235. package/src/ids/share-paths.test.ts +1 -1
  236. package/src/ids/tunnel-ids.test.ts +1 -1
  237. package/src/index.ts +10 -4
  238. package/src/models/agent-catalog.test.ts +1 -1
  239. package/src/models/capability-ledger.test.ts +1 -1
  240. package/src/models/model-offer.test.ts +9 -2
  241. package/src/models/model-offer.ts +10 -7
  242. package/src/models/model-order.test.ts +1 -1
  243. package/src/models/model-pins.test.ts +1 -1
  244. package/src/models/model-roles.test.ts +1 -1
  245. package/src/models/model-roles.ts +6 -12
  246. package/src/models/plan-pools.test.ts +1 -1
  247. package/src/models/provider-specs.test.ts +3 -3
  248. package/src/policy/batch-runs.test.ts +1 -1
  249. package/src/policy/command-classes.test.ts +37 -2
  250. package/src/policy/command-classes.ts +16 -0
  251. package/src/policy/credential-material.test.ts +6 -10
  252. package/src/policy/fence-paths.test.ts +1 -1
  253. package/src/policy/overlay-lint.test.ts +1 -1
  254. package/src/policy/owner-ticket.test.ts +1 -1
  255. package/src/policy/persona-home.test.ts +52 -0
  256. package/src/policy/persona-home.ts +39 -0
  257. package/src/policy/{card-status.ts → request-status.ts} +6 -6
  258. package/src/policy/safety-policy.test.ts +1 -1
  259. package/src/policy/safety-policy.ts +1 -0
  260. package/src/policy/search-globs.test.ts +1 -1
  261. package/src/policy/turned-away.ts +18 -0
  262. package/src/protocol/container-requirements.test.ts +1 -1
  263. package/src/protocol/ingress-contract.test.ts +8 -2
  264. package/src/protocol/ingress-protocol.test.ts +5 -4
  265. package/src/protocol/peer-dial.test.ts +111 -41
  266. package/src/protocol/peer-dial.ts +39 -4
  267. package/src/protocol/peer-mcp-server.test.ts +37 -7
  268. package/src/protocol/peer-mcp-server.ts +22 -1
  269. package/src/protocol/routes.test.ts +4 -2
  270. package/src/schemas/agent.ts +13 -0
  271. package/src/schemas/agents.ts +33 -0
  272. package/src/schemas/areas.test.ts +2 -8
  273. package/src/schemas/automations.ts +5 -5
  274. package/src/schemas/capabilities.ts +20 -9
  275. package/src/schemas/chat-route.ts +61 -0
  276. package/src/schemas/context-trim.test.ts +38 -0
  277. package/src/schemas/context-trim.ts +29 -0
  278. package/src/schemas/dev-rebuild-log.test.ts +1 -1
  279. package/src/schemas/devices.ts +25 -22
  280. package/src/schemas/extension-updates.ts +1 -1
  281. package/src/schemas/git/git.test.ts +11 -0
  282. package/src/schemas/git/git.ts +66 -0
  283. package/src/schemas/hosts.ts +15 -8
  284. package/src/schemas/issues.ts +1 -1
  285. package/src/schemas/metrics.ts +107 -0
  286. package/src/schemas/panels.ts +2 -2
  287. package/src/schemas/personas.ts +5 -28
  288. package/src/schemas/providers/plan-limits.ts +9 -0
  289. package/src/schemas/secrets.ts +1 -1
  290. package/src/schemas/settings.test.ts +1 -1
  291. package/src/schemas/settings.ts +9 -36
  292. package/src/schemas/shared.ts +6 -6
  293. package/src/schemas/system-prompt.ts +41 -0
  294. package/src/schemas/version-seam.test.ts +1 -1
  295. package/src/schemas/webext.ts +1 -1
  296. package/src/schemas/workflows.ts +4 -4
  297. package/src/state/contract-lock.test.ts +15 -11
  298. package/src/state/fix-attempt-plan.test.ts +10 -1
  299. package/src/state/fix-attempt-plan.ts +6 -2
  300. package/src/state/fix-stance.test.ts +10 -1
  301. package/src/state/fix-stance.ts +13 -0
  302. package/src/state/history-state.ts +17 -1
  303. package/src/state/runtime-state.test.ts +3 -9
  304. package/src/state/runtime-state.ts +0 -4
  305. package/src/state/versions.test.ts +1 -1
  306. package/src/state/workspace-state.test.ts +12 -7
  307. package/src/state/workspace-state.ts +4 -4
  308. package/src/text/archives.test.ts +1 -1
  309. package/src/text/documents.test.ts +1 -1
  310. package/src/text/documents.ts +2 -2
  311. package/src/text/embed.test.ts +17 -6
  312. package/src/text/emoji.test.ts +1 -1
  313. package/src/text/mentions.test.ts +20 -2
  314. package/src/text/mentions.ts +10 -1
  315. package/src/text/path-refs.test.ts +1 -1
  316. package/src/text/title.test.ts +1 -1
  317. package/src/text/transcript-fold.test.ts +183 -1
  318. package/src/text/transcript-fold.ts +146 -39
  319. package/src/text/whisper.test.ts +1 -1
  320. package/src/text/workflow-faults.test.ts +1 -1
  321. package/src/time/zone.test.ts +6 -6
  322. package/dist/contracts/host.contract.d.ts.map +0 -1
  323. package/dist/contracts/host.contract.js +0 -16
  324. package/dist/contracts/host.contract.js.map +0 -1
  325. package/dist/events/cards.d.ts.map +0 -1
  326. package/dist/events/cards.js.map +0 -1
  327. package/dist/policy/card-status.d.ts +0 -5
  328. package/dist/policy/card-status.d.ts.map +0 -1
  329. package/dist/policy/card-status.js.map +0 -1
  330. package/dist/schemas/model-route.d.ts +0 -27
  331. package/dist/schemas/model-route.d.ts.map +0 -1
  332. package/dist/schemas/model-route.js.map +0 -1
  333. package/src/schemas/model-route.ts +0 -43
@@ -1,5 +1,6 @@
1
- import { describe, expect, it } from "vitest";
2
- import { mentionedPathTokens, mentionPaths } from "./mentions.js";
1
+ import { describe, it, expect } from "bun:test";
2
+ import { AgentTurnSchema } from "../schemas/agent.js";
3
+ import { MENTION_LIMIT, mentionedPathTokens, mentionPaths } from "./mentions.js";
3
4
 
4
5
  describe(`mentionPaths`, () => {
5
6
  it(`extracts path-looking tokens, deduped, with trailing punctuation stripped`, () => {
@@ -13,6 +14,23 @@ describe(`mentionPaths`, () => {
13
14
  it(`skips scoped package script prefixes in copied pnpm output`, () => {
14
15
  expect(mentionPaths(`@intentic/iq-engine:test: failed\nsee @src/app.ts`)).toEqual([`src/app.ts`]);
15
16
  });
17
+
18
+ // A pasted `ps` dump attached a file nobody chose and the turn died on it: curl's `@file` argument is a mention's
19
+ // exact shape.
20
+ it(`skips curl's @file argument and anything else outside the workspace`, () => {
21
+ expect(mentionPaths(`curl --data-binary @/tmp/probe/req.json localhost`)).toEqual([]);
22
+ expect(mentionPaths(`@~/notes.md @C:\\tmp\\out.log @../outside.bin @.. done`)).toEqual([]);
23
+ expect(mentionPaths(`curl -d @/tmp/req.json x, then read @src/app.ts`)).toEqual([`src/app.ts`]);
24
+ });
25
+
26
+ // The wire takes MENTION_LIMIT paths: past it the schema would refuse the whole message, which is the failure a
27
+ // pasted log must not be able to cause.
28
+ it(`stops at the limit a turn can carry rather than overflowing it`, () => {
29
+ const pasted = Array.from({ length: MENTION_LIMIT + 5 }, (_, index) => `@src/file${index}.ts`).join(` `);
30
+
31
+ expect(mentionPaths(pasted)).toHaveLength(MENTION_LIMIT);
32
+ expect(AgentTurnSchema.safeParse({ prompt: pasted, mentions: mentionPaths(pasted) }).success).toBe(true);
33
+ });
16
34
  });
17
35
 
18
36
  describe(`mentionedPathTokens`, () => {
@@ -18,4 +18,13 @@ export const mentionedPathTokens = (text: string): string[] => {
18
18
  // Excludes a copied pnpm script prefix (`@scope/package:test:`), which shares the opening shape but is not a file;
19
19
  // accepting it creates a phantom attachment.
20
20
  const PACKAGE_SCRIPT = /^[^/]+\/[^/]+:[^/]+$/u;
21
- export const mentionPaths = (text: string): string[] => mentionedPathTokens(text).filter((token) => !PACKAGE_SCRIPT.test(token));
21
+ // A mention names a workspace-relative path and nothing else: the picker inserts one from the workspace index, an
22
+ // upload mints one under the state dir. An absolute, home, drive-letter or climbing token is therefore never a
23
+ // mention, and curl's `@file` argument (`--data-binary @/tmp/req.json`) wears exactly that shape in pasted output.
24
+ const OUTSIDE_WORKSPACE = /^(?:[/~]|[A-Za-z]:[\\/]|\.\.(?:[/\\]|$))/u;
25
+ // How many a turn may carry; the tokenizer stops here rather than letting a pasted log's 21st token refuse the turn.
26
+ export const MENTION_LIMIT = 20;
27
+ export const mentionPaths = (text: string): string[] =>
28
+ mentionedPathTokens(text)
29
+ .filter((token) => !PACKAGE_SCRIPT.test(token) && !OUTSIDE_WORKSPACE.test(token))
30
+ .slice(0, MENTION_LIMIT);
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "vitest";
1
+ import { describe, test, expect } from "bun:test";
2
2
  import { isTestPath, rankRefCandidates, referenceTails } from "./path-refs.js";
3
3
 
4
4
  describe("referenceTails", () => {
@@ -1,4 +1,4 @@
1
- import { expect, test } from "vitest";
1
+ import { test, expect } from "bun:test";
2
2
  import { AgentTurnSchema } from "../schemas/agent.js";
3
3
  import { deriveTitle } from "./title.js";
4
4
 
@@ -1,8 +1,9 @@
1
1
  import { STATE_DIR } from "@intentic/constants";
2
- import { describe, expect, it } from "vitest";
2
+ import { describe, it, expect } from "bun:test";
3
3
  import type { AgentEvent } from "../events/agent-events.js";
4
4
  import type { TranscriptPatch, TranscriptRow } from "../events/transcript.js";
5
5
  import { watchWakePrompt } from "../events/watch-wake.js";
6
+ import { childReportPrompt, peerMessagePrompt } from "../events/agent-words.js";
6
7
  import { applyTranscriptPatch, foldTurn, TranscriptFold, userRow } from "./transcript-fold.js";
7
8
 
8
9
  // Epoch ms stamped on the opening user row's sentAt.
@@ -443,6 +444,37 @@ describe("patches", () => {
443
444
  expect(fold.steerRows).toEqual([]);
444
445
  });
445
446
 
447
+ // Another agent's words are drawn as theirs, never as the owner's.
448
+ it("writes a peer's message and a child's report into a live turn as notices, not as the owner's words", () => {
449
+ const fold = new TranscriptFold(openingOf("ship it"));
450
+ fold.apply({
451
+ kind: "steer",
452
+ text: peerMessagePrompt({ from: "sharp-shale-htw8", title: "Bun migration", message: "done" }),
453
+ sentAt: SENT_AT + 1,
454
+ voice: "agent",
455
+ });
456
+ fold.apply({
457
+ kind: "steer",
458
+ text: childReportPrompt({ child: "sub-x7", title: "Port the parser", failed: false, report: "ported", verification: undefined }),
459
+ sentAt: SENT_AT + 2,
460
+ voice: "sandbox",
461
+ });
462
+ expect(fold.rows.slice(1)).toMatchObject([
463
+ { role: "notice", agentWords: { kind: "peer", from: "sharp-shale-htw8" } },
464
+ { role: "notice", agentWords: { kind: "child", from: "sub-x7" } },
465
+ ]);
466
+ expect(fold.steerRows).toEqual([]);
467
+ });
468
+
469
+ // An anchor from any other voice would shift every later person's anchor onto the wrong message.
470
+ it("anchors only a person's steer, even when another voice's words draw as a user row", () => {
471
+ const fold = new TranscriptFold(openingOf("ship it"));
472
+ fold.apply({ kind: "steer", text: "also cover the parser", sentAt: SENT_AT + 1, voice: "agent" });
473
+ fold.apply({ kind: "steer", text: "and the docs", sentAt: SENT_AT + 2 });
474
+ expect(fold.rows.map((row) => row.role)).toEqual(["user", "user", "user"]);
475
+ expect(fold.steerRows).toEqual([2]);
476
+ });
477
+
446
478
  it("drop an empty bubble the turn opened and abandoned", () => {
447
479
  const events: AgentEvent[] = [
448
480
  { kind: "todos", items: [] },
@@ -462,3 +494,153 @@ describe("patches", () => {
462
494
  expect(fold.rows[1]?.text).toBe("ab");
463
495
  });
464
496
  });
497
+
498
+ // A refusal that ran nothing offers the message back to the composer — true where somebody typed one, false for a run
499
+ // that started itself, where there is no composer and no typed message.
500
+ describe(`a refusal that ran nothing`, () => {
501
+ const REFUSED = { kind: `error`, code: `context-window-too-small`, message: `This model accepts 16,384 tokens.` } as const satisfies AgentEvent;
502
+
503
+ it(`holds a turn somebody typed for another press`, () => {
504
+ const rows = foldOf(`land it`, [REFUSED]);
505
+
506
+ expect(rows.at(-1)?.text).toContain(`held for you to send again`);
507
+ });
508
+
509
+ it(`promises no resend for a turn that started itself`, () => {
510
+ const rows = foldOf(`land it`, [{ ...REFUSED, unattended: true }]);
511
+
512
+ expect(rows.at(-1)?.text).not.toContain(`send again`);
513
+ expect(rows.at(-1)?.text).toContain(`started on its own`);
514
+ // The provider's own sentence still leads, whichever clause follows it.
515
+ expect(rows.at(-1)?.text).toContain(`16,384 tokens`);
516
+ });
517
+
518
+ // The daemon lets a person's next send through once it has warned them, so the row says so and always offers it;
519
+ // the raise joins only when the hold carried a ceiling to size it by, which a stall does not.
520
+ describe(`on a sandbox short of memory`, () => {
521
+ const GIB = 1024 ** 3;
522
+ const lowMemory = {
523
+ kind: `error`,
524
+ code: `sandbox-memory-low`,
525
+ message: `Sandbox memory is low: 12.0 GiB resident + 4.0 GiB swapped, against 16.0 GiB.`,
526
+ memory: { limitBytes: 16 * GIB, residentBytes: 12 * GIB, swapBytes: 4 * GIB },
527
+ } as const satisfies AgentEvent;
528
+
529
+ it(`tells the reader that sending again starts it, and offers both presses when there is a ceiling to raise`, () => {
530
+ const row = foldOf(`land it`, [lowMemory]).at(-1);
531
+ expect(row?.text).toBe(`${lowMemory.message} Your message is held: send it again to start anyway.`);
532
+ expect(row?.noticeAction).toBe(`sandboxMemory`);
533
+ });
534
+
535
+ it(`offers only the send on a stall, which names no ceiling`, () => {
536
+ const { memory: _stalled, ...noReading } = lowMemory;
537
+ const row = foldOf(`land it`, [noReading]).at(-1);
538
+ expect(row?.noticeAction).toBe(`sendAnyway`);
539
+ expect(row?.text).toContain(`send it again to start anyway`);
540
+ });
541
+
542
+ it(`offers nothing on a background turn, which has no message to send`, () => {
543
+ const row = foldOf(`land it`, [{ ...lowMemory, unattended: true }]).at(-1);
544
+ expect(row?.noticeAction).toBeUndefined();
545
+ expect(row?.text).toContain(`started on its own`);
546
+ });
547
+ });
548
+
549
+ // The other held refusals share the sentence, not the button: nothing about a dead credential is fixed by a cap.
550
+ it(`offers no press on the held refusals that a raise would not fix`, () => {
551
+ expect(foldOf(`land it`, [REFUSED]).at(-1)?.noticeAction).toBeUndefined();
552
+ });
553
+
554
+ // The bug this retraction exists for: the composer keeps the words for another press, so a bubble left standing
555
+ // here is the SAME message a second time, and a third, once per press against a gate that keeps refusing.
556
+ it(`takes the message back out, leaving the composer's copy as the only one`, () => {
557
+ const fold = new TranscriptFold(openingOf(`land it`));
558
+ const patches = fold.apply(REFUSED);
559
+
560
+ expect(fold.rows).toEqual([{ role: `notice`, text: expect.stringContaining(`held for you to send again`) }]);
561
+ expect(fold.ranNothing).toBe(true);
562
+ // Dropped ahead of the notice that stands in for it: a window applies patches in order, and an append first
563
+ // would have it renumber every row under an index that is about to move.
564
+ expect(patches).toEqual([
565
+ { op: `drop`, index: 0 },
566
+ { op: `append`, row: expect.objectContaining({ role: `notice` }) },
567
+ ]);
568
+ });
569
+
570
+ // Three presses is what the screenshot of this bug showed: one paragraph, four times, with a refusal between each.
571
+ it(`leaves nothing behind however many times the same message is refused`, () => {
572
+ const pressed = [0, 1, 2].map(() => foldOf(`land it`, [REFUSED]));
573
+
574
+ expect(pressed.flat().filter((row) => row.role === `user`)).toEqual([]);
575
+ });
576
+
577
+ it(`keeps the message of a run that started itself, which has no composer to repeat from`, () => {
578
+ const fold = new TranscriptFold(openingOf(`land it`));
579
+ fold.apply({ ...REFUSED, unattended: true });
580
+
581
+ expect(fold.rows[0]).toEqual(expect.objectContaining({ role: `user`, text: `land it` }));
582
+ expect(fold.ranNothing).toBe(false);
583
+ });
584
+
585
+ // A refusal is only a retraction where it refused the whole turn. One arriving after the agent has spoken ends a
586
+ // turn that happened, and everything it said is kept and recorded.
587
+ it(`keeps a turn the refusal interrupted rather than prevented`, () => {
588
+ const fold = new TranscriptFold(openingOf(`land it`));
589
+ fold.apply({ kind: `delta`, text: `on it` });
590
+ fold.apply(REFUSED);
591
+
592
+ expect(fold.rows.map((row) => row.role)).toEqual([`user`, `assistant`, `notice`]);
593
+ expect(fold.ranNothing).toBe(false);
594
+ });
595
+
596
+ // The sandbox, not a composer, keeps a turn it started itself (a fix press, a peer's message): the frame says so
597
+ // with `held`. The bug this exists for: such a refusal took the fix prompt out, and no queue anywhere held a copy.
598
+ describe(`of a turn the sandbox keeps`, () => {
599
+ const kept = { ...REFUSED, held: { ran: false } } as const satisfies AgentEvent;
600
+
601
+ it(`leaves the message where it was, and is recorded like any turn`, () => {
602
+ const fold = new TranscriptFold(openingOf(`land it`));
603
+ const patches = fold.apply(kept);
604
+
605
+ expect(fold.rows[0]).toEqual(expect.objectContaining({ role: `user`, text: `land it` }));
606
+ expect(fold.ranNothing).toBe(false);
607
+ expect(patches).toEqual([{ op: `append`, row: expect.objectContaining({ role: `notice` }) }]);
608
+ });
609
+
610
+ it(`offers to send it again, as the sandbox's to run rather than the composer's`, () => {
611
+ const row = foldOf(`land it`, [kept]).at(-1);
612
+ expect(row?.text).toBe(`${REFUSED.message} Nothing has run yet: the message above is kept here to send again once that is sorted.`);
613
+ expect(row).toMatchObject({ noticeAction: `sendAgain`, sandboxHeld: true });
614
+ });
615
+
616
+ it(`offers the memory hold's own presses, anyway and the raise`, () => {
617
+ const GIB = 1024 ** 3;
618
+ const lowMemory = {
619
+ kind: `error`,
620
+ code: `sandbox-memory-low`,
621
+ message: `Sandbox memory is low: 15.9 GiB of 16.0 GiB used.`,
622
+ memory: { limitBytes: 16 * GIB, residentBytes: 15.9 * GIB, swapBytes: 0 },
623
+ held: { ran: false },
624
+ } as const satisfies AgentEvent;
625
+ const row = foldOf(`land it`, [lowMemory]).at(-1);
626
+ expect(row?.text).toBe(`${lowMemory.message} Nothing has run yet: the message above is kept here, and sending it anyway starts it.`);
627
+ expect(row).toMatchObject({ noticeAction: `sandboxMemory`, sandboxHeld: true });
628
+ });
629
+ });
630
+
631
+ // Asked by whoever keeps the words before the frame is folded, so it has to agree with the fold's own reading.
632
+ describe(`turned away at the door`, () => {
633
+ it(`is a refusal before anything was said, with somebody watching`, () => {
634
+ const fold = new TranscriptFold(openingOf(`land it`));
635
+ expect(fold.turnedAway(REFUSED)).toBe(true);
636
+ expect(fold.turnedAway({ ...REFUSED, unattended: true })).toBe(false);
637
+ expect(fold.turnedAway({ kind: `error`, code: `rate_limit`, message: `Usage limit reached.` })).toBe(false);
638
+ });
639
+
640
+ it(`is not a refusal that ended a turn which had already spoken`, () => {
641
+ const fold = new TranscriptFold(openingOf(`land it`));
642
+ fold.apply({ kind: `delta`, text: `on it` });
643
+ expect(fold.turnedAway(REFUSED)).toBe(false);
644
+ });
645
+ });
646
+ });
@@ -1,8 +1,19 @@
1
- import { cancelledCards, settledCards } from "../policy/card-status.js";
1
+ import { cancelledRequests, settledRequests } from "../policy/request-status.js";
2
2
  import type { AgentEvent } from "../events/agent-events.js";
3
- import { CARD_FIELDS, holdsCard, isAwaitingDecision, type TranscriptCards, type TranscriptPatch, type TranscriptRow, type TranscriptSubagent, type TranscriptTool } from "../events/transcript.js";
3
+ import {
4
+ REQUEST_FIELDS,
5
+ holdsRequest,
6
+ isAwaitingDecision,
7
+ type TranscriptRequests,
8
+ type TranscriptPatch,
9
+ type TranscriptRow,
10
+ type TranscriptSubagent,
11
+ type TranscriptTool,
12
+ } from "../events/transcript.js";
13
+ import { contextTrimLine } from "../schemas/context-trim.js";
14
+ import { turnedAwayCode } from "../policy/turned-away.js";
4
15
  import { mentionedPathTokens } from "./mentions.js";
5
- import { watchWakeRow } from "../events/watch-wake.js";
16
+ import { unspokenPromptRow } from "../events/agent-words.js";
6
17
 
7
18
  // Folds a turn's frames into rows once, live and for the settled record alike, so a reopened chat matches what was on
8
19
  // screen. `tag` selects the stream read: undefined is the main turn, a tool-call id is the subagent it spawned; other
@@ -18,7 +29,8 @@ interface CardPlace {
18
29
  }
19
30
 
20
31
  // Strips undefined fields, so spreading a partial frame never overwrites a field an earlier frame already set.
21
- const defined = <T extends object>(value: T): Partial<T> => Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) as Partial<T>;
32
+ const defined = <T extends object>(value: T): Partial<T> =>
33
+ Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) as Partial<T>;
22
34
 
23
35
  const cardOf = (event: Extract<AgentEvent, { kind: "tool_call" }>): TranscriptTool => ({
24
36
  id: event.id,
@@ -30,6 +42,11 @@ const cardOf = (event: Extract<AgentEvent, { kind: "tool_call" }>): TranscriptTo
30
42
  ...(event.content !== undefined ? { content: event.content } : {}),
31
43
  });
32
44
 
45
+ // Whether a just-retired prose bubble says exactly what the plan about to be drawn says, so the request can take its
46
+ // place instead of the same markdown appearing twice in a row.
47
+ const restates = (row: TranscriptRow | undefined, text: string): boolean =>
48
+ row?.role === "assistant" && !holdsRequest(row) && row.text.trim() !== "" && row.text.trim() === text.trim();
49
+
33
50
  // Whether a bubble has any content: text, thinking, tools, todos, usage or a card; empty otherwise.
34
51
  const empty = (row: TranscriptRow): boolean =>
35
52
  row.text.length === 0 &&
@@ -37,7 +54,7 @@ const empty = (row: TranscriptRow): boolean =>
37
54
  (row.tools?.length ?? 0) === 0 &&
38
55
  (row.todos?.length ?? 0) === 0 &&
39
56
  row.usage === undefined &&
40
- !holdsCard(row);
57
+ !holdsRequest(row);
41
58
 
42
59
  // Clause appended to the landed notice for a workspace dependency change, or empty when there is none. Reports the
43
60
  // install as already started, or queued when other agents are still running; never as a request.
@@ -89,6 +106,46 @@ const landedRow = (event: Extract<AgentEvent, { kind: "landed" }>): TranscriptRo
89
106
  };
90
107
  };
91
108
 
109
+ // The clause after a refusal that ran nothing. A turn somebody typed is held in the composer for another press; a turn
110
+ // that started itself — an automation, a loop, a watch wake — has no typed message and nobody watching, so promising a
111
+ // resend there names a composer the reader is not looking at. Neither version promises a retry: whether one comes is
112
+ // the scheduler's decision, not this row's to state.
113
+ const undelivered = (unattended: boolean): string =>
114
+ unattended
115
+ ? `Nothing ran, and nothing is held: this run started on its own, so there is no message waiting to be sent again.`
116
+ : `Your message was not delivered: it is held for you to send again.`;
117
+
118
+ // The press a memory hold offers: the raise rides along only when the hold carried a ceiling to size it by, since a
119
+ // stall names none and raising one would not clear it.
120
+ const memoryPress = (event: Extract<AgentEvent, { kind: "error" }>): "sendAnyway" | "sandboxMemory" =>
121
+ event.memory === undefined ? "sendAnyway" : "sandboxMemory";
122
+
123
+ // A memory hold is asked once and never again that spell (memory-admission.ts), so the next send always runs. A turn the
124
+ // sandbox started itself (a fix press, a peer's message, a re-run) was never in a composer: its message stays above, and
125
+ // the press asks the sandbox to run the turn it kept rather than sending a queue that holds nothing.
126
+ const heldRow = (event: Extract<AgentEvent, { kind: "error" }>): TranscriptRow => {
127
+ const unattended = event.unattended === true;
128
+ const memory = event.code === "sandbox-memory-low";
129
+ if (event.held !== undefined && !unattended) {
130
+ return {
131
+ role: "notice",
132
+ text: memory
133
+ ? `${event.message} Nothing has run yet: the message above is kept here, and sending it anyway starts it.`
134
+ : `${event.message} Nothing has run yet: the message above is kept here to send again once that is sorted.`,
135
+ noticeAction: memory ? memoryPress(event) : "sendAgain",
136
+ sandboxHeld: true,
137
+ };
138
+ }
139
+ if (!memory || unattended) {
140
+ return { role: "notice", text: `${event.message} ${undelivered(unattended)}` };
141
+ }
142
+ return {
143
+ role: "notice",
144
+ text: `${event.message} Your message is held: send it again to start anyway.`,
145
+ noticeAction: memoryPress(event),
146
+ };
147
+ };
148
+
92
149
  // Row for a turn-ending error: the provider's own message plus one clause on what happens next. The live wait itself is
93
150
  // drawn by the chat, not stored here.
94
151
  const errorRow = (event: Extract<AgentEvent, { kind: "error" }>): TranscriptRow => {
@@ -100,22 +157,21 @@ const errorRow = (event: Extract<AgentEvent, { kind: "error" }>): TranscriptRow
100
157
  : { role: "notice", text: `${message} Retrying by itself: attempt ${event.outage.attempt} of ${event.outage.maxAttempts}.` };
101
158
  case "claude-token-refused":
102
159
  return event.autoResume === "scheduled"
103
- ? { role: "notice", text: `${message} The credential is being renewed and this turn continues automatically.`, noticeWait: "credentialRenewal" }
160
+ ? {
161
+ role: "notice",
162
+ text: `${message} The credential is being renewed and this turn continues automatically.`,
163
+ noticeWait: "credentialRenewal",
164
+ }
104
165
  : { role: "notice", text: `${message} Reconnect the account to pick this conversation back up.` };
105
166
  case "rate_limit":
106
167
  return { role: "notice", text: message };
107
- // Refused before the model saw it; the composer holds the message so the user can resend it.
108
- case "claude-reauth":
109
- case "unknown-command":
110
- case "context-window-too-small":
111
- case "sandbox-memory-low":
112
- case "trial-unavailable":
113
- case "trial-model-unavailable":
114
- case "trial-exhausted":
115
- return { role: "notice", text: `${message} Your message was not delivered: it is held for you to send again.` };
116
168
  default:
117
- return { role: "notice", text: message };
169
+ break;
118
170
  }
171
+ if (!turnedAwayCode(code)) {
172
+ return { role: "notice", text: message };
173
+ }
174
+ return heldRow(event);
119
175
  };
120
176
 
121
177
  // The turn's opening user row: text, timestamp, attachments, and whatever the daemon later stamps onto it (checkpoint,
@@ -131,13 +187,17 @@ export class TranscriptFold {
131
187
  readonly rows: TranscriptRow[] = [];
132
188
  // Row index of each user steer, in order; the daemon anchors rewind state to these once the turn settles.
133
189
  readonly steerRows: number[] = [];
190
+ // Whether anything was steered in, whoever spoke: a retract may not take back an opening something followed.
191
+ private steeredIn = false;
134
192
  // Index of the open assistant bubble; always the last row, since every other row kind closes it first.
135
193
  private bubble: number | undefined;
136
194
  private readonly cards = new Map<string, CardPlace>();
137
195
  // requestId to the row holding its card, for frames landing on it later (a reply, a late sentence, a receipt).
138
196
  private readonly parked = new Map<string, number>();
139
- // The turn's opening user row, where the checkpoint and daemon notes land.
140
- private readonly opener: number | undefined;
197
+ // The turn's opening user row, where the checkpoint and daemon notes land; cleared once `retract` takes it back.
198
+ private opener: number | undefined;
199
+ // Set by `retract`: this turn was refused before it ran, and its message went back to the composer.
200
+ private unrun = false;
141
201
 
142
202
  constructor(
143
203
  opening: readonly TranscriptRow[],
@@ -246,6 +306,10 @@ export class TranscriptFold {
246
306
  case "preamble":
247
307
  // Collapses the daemon's preamble notes onto the user row; an empty note list is not a disclosure.
248
308
  return event.notes.length === 0 ? [] : this.stampOpener((row) => (row.notes = [...event.notes]));
309
+ case "context_trim":
310
+ // A row of its own rather than a stamp on the message: what was left out is not part of what was sent,
311
+ // and the fold beside the message only ever lists notes that actually rode.
312
+ return this.pushRow({ role: "notice", text: contextTrimLine(event) });
249
313
  case "worktree":
250
314
  return event.sync === undefined ? [] : this.pushRow({ role: "notice", text: syncLine(event.sync) });
251
315
  case "landed":
@@ -254,17 +318,13 @@ export class TranscriptFold {
254
318
  return this.pushRow({ role: "notice", text: `Context compacted to free up space.` });
255
319
  case "error":
256
320
  // Keeps a refusal (no prose from the provider) from reading as a session that ended mid-question.
257
- return this.pushRow(errorRow(event));
321
+ // A refusal that ran nothing takes its message back out ahead of the notice standing in for it.
322
+ return [...this.retract(event), ...this.pushRow(errorRow(event))];
258
323
  case "plan": {
259
324
  // Folds a plan into an identical retired prose bubble instead of drawing the same markdown twice.
260
325
  const adjacent = this.rows.at(-1);
261
- const consumes =
262
- this.bubble === undefined &&
263
- adjacent?.role === "assistant" &&
264
- !holdsCard(adjacent) &&
265
- adjacent.text.trim() !== "" &&
266
- adjacent.text.trim() === event.text.trim();
267
- if (consumes) {
326
+ const consumes = this.bubble === undefined && restates(adjacent, event.text);
327
+ if (consumes && adjacent !== undefined) {
268
328
  adjacent.text = "";
269
329
  }
270
330
  return this.park(
@@ -309,7 +369,7 @@ export class TranscriptFold {
309
369
  return this.park(event.requestId, { credentialOffer: { requestId: event.requestId, offer: event.offer, status: "pending" } });
310
370
  case "resolved":
311
371
  // Releases the card; the answering window already froze it locally, so this is a no-op there.
312
- return this.patchParked(event.requestId, (row) => Object.assign(row, settledCards(row, event.reply)));
372
+ return this.patchParked(event.requestId, (row) => Object.assign(row, settledRequests(row, event.reply)));
313
373
  case "capability_outcome":
314
374
  return this.patchParked(event.requestId, (row) => {
315
375
  if (row.capabilityOffer !== undefined) {
@@ -353,14 +413,12 @@ export class TranscriptFold {
353
413
  }
354
414
  }
355
415
 
356
- /**
357
- * A message pushed into a turn already running. A condition watch's wake arrives this way too, and it is the
358
- * daemon's own words: it becomes a notice, and never an anchor the rewind can return a person to.
359
- */
416
+ /** A message pushed into a running turn; only a person's own message is a rewind anchor. */
360
417
  private steered(event: Extract<AgentEvent, { kind: "steer" }>): TranscriptPatch[] {
361
- const wake = watchWakeRow(event.text);
362
- if (wake !== undefined) {
363
- return this.pushRow(wake);
418
+ this.steeredIn = true;
419
+ const unspoken = unspokenPromptRow(event.text);
420
+ if (unspoken !== undefined) {
421
+ return this.pushRow(unspoken);
364
422
  }
365
423
  // A steer also closes the bubble, or the next answer would print over it mid-call.
366
424
  const patches = this.pushRow({
@@ -369,7 +427,10 @@ export class TranscriptFold {
369
427
  sentAt: event.sentAt,
370
428
  ...(event.attachments === undefined ? {} : { attachments: [...event.attachments] }),
371
429
  });
372
- this.steerRows.push(this.rows.length - 1);
430
+ // Anchors pair by position with the checkpoints only a person's steer reserves.
431
+ if (event.voice === undefined) {
432
+ this.steerRows.push(this.rows.length - 1);
433
+ }
373
434
  return patches;
374
435
  }
375
436
 
@@ -378,6 +439,46 @@ export class TranscriptFold {
378
439
  return this.pushRow(row);
379
440
  }
380
441
 
442
+ /**
443
+ * Whether the turn ran nothing: refused before the model saw it, message handed back; never recorded (turn-runs).
444
+ * Re-derived rather than latched, so a stream that spoke after its refusal still keeps what it said.
445
+ */
446
+ get ranNothing(): boolean {
447
+ return this.unrun && !this.rows.some((row) => row.role === "assistant");
448
+ }
449
+
450
+ /**
451
+ * Whether this frame turns the whole turn away at the door: a refusal before the model saw a word, with somebody
452
+ * watching and nothing yet said or steered in. Asked before the frame is applied, by whoever keeps the words.
453
+ */
454
+ turnedAway(event: AgentEvent): boolean {
455
+ return (
456
+ event.kind === "error" &&
457
+ turnedAwayCode(event.code) &&
458
+ event.unattended !== true &&
459
+ !this.rows.some((row) => row.role === "assistant") &&
460
+ !this.steeredIn
461
+ );
462
+ }
463
+
464
+ // Attended refusals only: the composer holds those words for another press, so a bubble left standing repeats them
465
+ // once per press. Splicing is safe because nothing ran — no open bubble, no parked card, no steer above it. A turn
466
+ // the sandbox kept (`held`) keeps its message and is recorded: no composer anywhere holds a copy of it.
467
+ private retract(event: Extract<AgentEvent, { kind: "error" }>): TranscriptPatch[] {
468
+ if (!this.turnedAway(event) || event.held !== undefined) {
469
+ return [];
470
+ }
471
+ this.unrun = true;
472
+ const opener = this.opener;
473
+ // An opening the daemon wrote (a watch wake, a resume disclosure) has no bubble to take back, and is still unrun.
474
+ if (opener === undefined) {
475
+ return [];
476
+ }
477
+ this.rows.splice(opener, 1);
478
+ this.opener = undefined;
479
+ return [{ op: "drop", index: opener }];
480
+ }
481
+
381
482
  /**
382
483
  * Ends the turn: closes the open bubble, freezes every still-pending card as nobody's decision, and notes a user
383
484
  * stop.
@@ -386,7 +487,7 @@ export class TranscriptFold {
386
487
  const patches = this.closeBubble();
387
488
  for (const [index, row] of this.rows.entries()) {
388
489
  if (row.role === "assistant" && isAwaitingDecision(row)) {
389
- Object.assign(row, cancelledCards(row));
490
+ Object.assign(row, cancelledRequests(row));
390
491
  patches.push(this.replace(index));
391
492
  }
392
493
  }
@@ -453,7 +554,7 @@ export class TranscriptFold {
453
554
 
454
555
  // A card takes the open bubble and closes it; `into` reuses an existing row (a plan's own prose) instead of opening
455
556
  // a new one.
456
- private park(requestId: string, cards: TranscriptCards, into?: number): TranscriptPatch[] {
557
+ private park(requestId: string, cards: TranscriptRequests, into?: number): TranscriptPatch[] {
457
558
  const [index, opened] = into === undefined ? this.open() : [into, []];
458
559
  Object.assign(this.rows[index]!, cards);
459
560
  this.bubble = undefined;
@@ -493,7 +594,12 @@ export class TranscriptFold {
493
594
  }
494
595
 
495
596
  /** Folds a whole turn at once: opening rows, every frame, and how it ended; what a settled turn reads back as. */
496
- export const foldTurn = (opening: readonly TranscriptRow[], events: readonly AgentEvent[], ending: TurnEnding = "settled", tag?: string): TranscriptRow[] => {
597
+ export const foldTurn = (
598
+ opening: readonly TranscriptRow[],
599
+ events: readonly AgentEvent[],
600
+ ending: TurnEnding = "settled",
601
+ tag?: string,
602
+ ): TranscriptRow[] => {
497
603
  const fold = new TranscriptFold(opening, tag);
498
604
  for (const event of events) {
499
605
  fold.apply(event);
@@ -561,4 +667,5 @@ export const mapTool = (tools: readonly TranscriptTool[], id: string, fn: (tool:
561
667
  };
562
668
 
563
669
  // Which row fields are cards, exported for readers that count rows by them.
564
- export const cardFieldsOf = (row: TranscriptRow): TranscriptCards => Object.fromEntries(CARD_FIELDS.flatMap((field) => (row[field] === undefined ? [] : [[field, row[field]]])));
670
+ export const cardFieldsOf = (row: TranscriptRow): TranscriptRequests =>
671
+ Object.fromEntries(REQUEST_FIELDS.flatMap((field) => (row[field] === undefined ? [] : [[field, row[field]]])));
@@ -1,4 +1,4 @@
1
- import { expect, test } from "vitest";
1
+ import { test, expect } from "bun:test";
2
2
  import { cleanTranscription } from "./whisper.js";
3
3
 
4
4
  test("flattens whisper output onto one line", () => {
@@ -1,4 +1,4 @@
1
- import { expect, test } from "vitest";
1
+ import { test, expect } from "bun:test";
2
2
  import { type Workflow, WorkflowSchema, type WorkflowStep } from "../schemas/workflows.js";
3
3
  import { workflowFaults, workflowRunFaults } from "./workflow-faults.js";
4
4
 
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "vitest";
1
+ import { describe, test, expect } from "bun:test";
2
2
  import { asCivilDay, asZone, civilDayIn, cronOptions, isZone, sameClock, utcDayOf, UTC, zoneLabel } from "./zone.js";
3
3
  import type { Zone } from "./zone.js";
4
4
 
@@ -28,7 +28,7 @@ describe(`isZone`, () => {
28
28
  });
29
29
 
30
30
  test(`asZone passes real ids through and swallows the rest`, () => {
31
- expect(asZone(`America/New_York`)).toBe(`America/New_York`);
31
+ expect<string | undefined>(asZone(`America/New_York`)).toBe(`America/New_York`);
32
32
  expect(asZone(`Mars/Olympus`)).toBeUndefined();
33
33
  expect(asZone(undefined)).toBeUndefined();
34
34
  });
@@ -39,9 +39,9 @@ describe(`civilDayIn`, () => {
39
39
  test(`an instant falls on different days depending on the zone asked`, () => {
40
40
  // 2026-09-20T23:30Z: already the 21st in Warsaw, still the 20th in UTC and New York.
41
41
  const lateEvening = Date.UTC(2026, 8, 20, 23, 30);
42
- expect(civilDayIn(lateEvening, WARSAW)).toBe(`2026-09-21`);
43
- expect(civilDayIn(lateEvening, UTC)).toBe(`2026-09-20`);
44
- expect(civilDayIn(lateEvening, NEW_YORK)).toBe(`2026-09-20`);
42
+ expect<string>(civilDayIn(lateEvening, WARSAW)).toBe(`2026-09-21`);
43
+ expect<string>(civilDayIn(lateEvening, UTC)).toBe(`2026-09-20`);
44
+ expect<string>(civilDayIn(lateEvening, NEW_YORK)).toBe(`2026-09-20`);
45
45
  });
46
46
 
47
47
  test(`utcDayOf is civilDayIn UTC, spelled so every UTC bucket is greppable`, () => {
@@ -49,7 +49,7 @@ describe(`civilDayIn`, () => {
49
49
  });
50
50
 
51
51
  test(`asCivilDay takes the shape and nothing else`, () => {
52
- expect(asCivilDay(`2026-09-20`)).toBe(`2026-09-20`);
52
+ expect<string | undefined>(asCivilDay(`2026-09-20`)).toBe(`2026-09-20`);
53
53
  expect(asCivilDay(`2026-09-20T00:00:00Z`)).toBeUndefined();
54
54
  expect(asCivilDay(`20/09/2026`)).toBeUndefined();
55
55
  });
@@ -1 +0,0 @@
1
- {"version":3,"file":"host.contract.d.ts","sourceRoot":"","sources":["../../src/contracts/host.contract.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AASxB,eAAO,MAAM,YAAY;IAErB,QAAQ;;;;;;;;;;;;;;;;IAER,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAET,IAAI;;;IAEJ,GAAG;IAEH,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAEd,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CACf,CAAC"}
@@ -1,16 +0,0 @@
1
- import { oc } from "@orpc/contract";
2
- import { streamOf } from "../protocol/routes.js";
3
- import { z } from "zod";
4
- import { DeviceAgentFlowSchema, DeviceFlowLineSchema, DeviceSandboxFlowSchema } from "../schemas/devices.js";
5
- import { HostScopesSchema } from "../schemas/capabilities.js";
6
- import { HostFactsSchema } from "../schemas/hosts.js";
7
- import { OkSchema } from "../schemas/shared.js";
8
- export const hostContract = {
9
- describe: oc.output(HostFactsSchema),
10
- setScopes: oc.input(HostScopesSchema).output(OkSchema),
11
- ping: oc.output(OkSchema),
12
- mcp: oc.input(z.unknown()).output(z.unknown()),
13
- runSandboxFlow: oc.input(DeviceSandboxFlowSchema).output(streamOf(DeviceFlowLineSchema)),
14
- runAgentFlow: oc.input(DeviceAgentFlowSchema).output(streamOf(DeviceFlowLineSchema)),
15
- };
16
- //# sourceMappingURL=host.contract.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"host.contract.js","sourceRoot":"","sources":["../../src/contracts/host.contract.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,MAAM,gBAAgB,CAAC;AACpC,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAC7G,OAAO,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAKhD,MAAM,CAAC,MAAM,YAAY,GAAG;IAExB,QAAQ,EAAE,EAAE,CAAC,MAAM,CAAC,eAAe,CAAC;IAEpC,SAAS,EAAE,EAAE,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC;IAEtD,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC;IAEzB,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC;IAE9C,cAAc,EAAE,EAAE,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAC;IAExF,YAAY,EAAE,EAAE,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAC,CAAC;CACvF,CAAC"}