@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
@@ -18,6 +18,7 @@ import {
18
18
  ProviderRefusalsSchema,
19
19
  QueuedMessageRefSchema,
20
20
  QueueEditSchema,
21
+ QueueContinueSchema,
21
22
  QueueResumeSchema,
22
23
  QueueScheduleSchema,
23
24
  ResumeTurnSchema,
@@ -138,6 +139,18 @@ export const agentContract = {
138
139
  .meta({ floor: "collaborator", guest: true })
139
140
  .input(QueueScheduleSchema)
140
141
  .output(ConversationQueueSchema),
142
+ // NOT_FOUND for a conversation the sandbox does not have; BAD_REQUEST when the conversation named to wait for cannot land.
143
+ queueContinue: procedure
144
+ .route({
145
+ method: "POST",
146
+ path: "/agent/queue/continue",
147
+ summary: "Book a Continue for later",
148
+ description:
149
+ "Books the conversation to carry on from where its last turn stopped at an instant, or once another conversation has finished and its work has landed: a Continue that waits in the queue, drawn as scheduled, rather than words. Nothing is added to the conversation as your message. When it goes, a turn the sandbox still holds runs again as it was; otherwise the agent is told to continue in its own session. A turn that starts in the conversation before then makes it moot, and it is dropped. Take it back with queueRemove, send it sooner with queueResume, re-time it with queueSchedule.",
150
+ })
151
+ .meta({ floor: "collaborator", guest: true })
152
+ .input(QueueContinueSchema)
153
+ .output(ConversationQueueSchema),
141
154
  // CONFLICT while a turn already runs (the client follows it); NOT_FOUND when nothing is held (it falls back to `run`).
142
155
  resume: procedure
143
156
  .route({
@@ -45,6 +45,7 @@ import {
45
45
  StashPushSchema,
46
46
  StashRefParamSchema,
47
47
  } from "../schemas/git/git-history.js";
48
+ import { IndexRunSchema, IndexRunStartSchema } from "../schemas/git/index-run.js";
48
49
  import { FileDiffSchema } from "../schemas/history.js";
49
50
  import { OkSchema, RepoParamSchema } from "../schemas/shared.js";
50
51
 
@@ -465,6 +466,38 @@ export const gitContract = {
465
466
  })
466
467
  .input(RepoParamSchema)
467
468
  .output(OkSchema),
469
+ // Stage-all and commit-all as runs (index-run.ts): a stage over tens of thousands of files outlives the browser's
470
+ // header deadline, so it starts, answers at once and is polled, like a push.
471
+ indexRunStart: procedure
472
+ .route({
473
+ method: "POST",
474
+ path: "/git/{repo}/index-run",
475
+ summary: "Start staging or committing",
476
+ description:
477
+ "Starts a stage or a commit and answers at once with the run; poll indexRunState for its progress and verdict. One per repository: starting the same thing again while it runs joins it, and starting something different is refused until it settles. A stage that would take thousands of files or hundreds of megabytes stops before touching the index and says what it found, unless `confirmed` is set.",
478
+ })
479
+ .input(IndexRunStartSchema)
480
+ .output(IndexRunSchema),
481
+ indexRunState: procedure
482
+ .route({
483
+ method: "GET",
484
+ path: "/git/{repo}/index-run",
485
+ summary: "How the stage or commit is going",
486
+ description:
487
+ "The repository's latest stage or commit run: its phase and file counts while it runs, then its verdict, the commit's answer, or what held it at the guardrail. Idle when none has been started since the sandbox last started.",
488
+ })
489
+ .input(RepoParamSchema)
490
+ .output(IndexRunSchema),
491
+ indexRunCancel: procedure
492
+ .route({
493
+ method: "POST",
494
+ path: "/git/{repo}/index-run/cancel",
495
+ summary: "Stop the stage or commit",
496
+ description:
497
+ "Kills the git the run is waiting on. It settles as cancelled; a stage stopped part way leaves the index as git last wrote it, which unstaging takes back, and a commit stopped before git recorded it records nothing.",
498
+ })
499
+ .input(RepoParamSchema)
500
+ .output(OkSchema),
468
501
  files: procedure
469
502
  .route({
470
503
  method: "GET",
@@ -35,6 +35,8 @@ import {
35
35
  WorkspaceMediaTicketQuerySchema,
36
36
  WorkspaceMediaTicketSchema,
37
37
  WorkspaceDownloadTicketQuerySchema,
38
+ WorkspacePreviewTicketQuerySchema,
39
+ WorkspacePreviewTicketSchema,
38
40
  WorkspaceDownloadTicketSchema,
39
41
  WorkspaceMoveSchema,
40
42
  WorkspaceResolveQuerySchema,
@@ -134,6 +136,19 @@ export const workspaceContract = {
134
136
  .meta({ floor: "viewer", guest: true })
135
137
  .input(WorkspaceDownloadTicketQuerySchema)
136
138
  .output(WorkspaceDownloadTicketSchema),
139
+ // Mints the ticket GET /workspace/preview/{ticket}/* (a plain Hono route serving a page and its files) requires.
140
+ previewTicket: procedure
141
+ .route({
142
+ method: "POST",
143
+ path: "/workspace/preview-ticket",
144
+ summary: "Get an address to preview a web page at",
145
+ description:
146
+ "Mints a short-lived address a frame loads a workspace web page from, so the page runs as it would on a static file server: its scripts, modules, fetches and links resolve against real addresses beside it. The address serves the files of the page's own repository, never hidden files or the sandbox's own state, and the page runs sandboxed, with no access to the editor or this API.",
147
+ })
148
+ // Previewing a page is reading it and the files beside it, and the ticket is narrower than the bearer.
149
+ .meta({ floor: "viewer", guest: true })
150
+ .input(WorkspacePreviewTicketQuerySchema)
151
+ .output(WorkspacePreviewTicketSchema),
137
152
  // Matches a prose-written path, often just a suffix, against the tree rather than trusting it as root-relative.
138
153
  resolve: procedure
139
154
  .route({
@@ -13,6 +13,7 @@ import { RetryLadderSchema } from "../schemas/turn-break.js";
13
13
  import { HandedOffSchema, HandoffOfferSchema } from "../schemas/providers/handoff.js";
14
14
  import { AgentCommandSchema, browserHelpRequest, capabilityOfferRequest, CapabilityOutcomeSchema, ContextUsageSchema, credentialOfferRequest, CredentialReceiptSchema, pageAskRequest, PageSchema, paymentOfferRequest, PaymentReceiptSchema, PermissionRequestSchema, PlanRequestSchema, QuestionRequestSchema, terminalHelpRequest, TodoItemSchema, ToolCallContentSchema, ToolCallLocationSchema, ToolCallStatusSchema, ToolKindSchema } from "./requests.js";
15
15
  import { TranscriptPatchSchema, TranscriptRowSchema, TurnNoteSchema } from "./transcript.js";
16
+ import { ReviewFindingsSchema } from "./review.js";
16
17
  import { agentNoticeEvent, agentStatusEvent } from "./agent-ui.js";
17
18
 
18
19
  // Every frame an agent turn streams, as one `kind`-discriminated union: turn facts (session, standing, cost) and the
@@ -239,6 +240,9 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
239
240
  // A page the agent showed (its `show_page` tool): drawn inline where the turn stands, in a sealed frame. Parks
240
241
  // nothing. A page whose id an earlier one already holds is a redraw, and the earlier row folds to a line.
241
242
  z.object({ kind: z.literal("page"), page: PageSchema }),
243
+ // A code review's findings (events/review.ts), drawn inline as one card. A review whose id an earlier one holds is
244
+ // its re-report, its fixes applied, and the earlier card folds to a line, as a page's redraw does.
245
+ z.object({ kind: z.literal("review"), review: ReviewFindingsSchema }),
242
246
  // A page the agent showed to be answered (`ask_page`): parks the turn until the page sends its answer back, or the
243
247
  // person dismisses it. Not journalled for restore: the tool call waiting on it dies with the daemon.
244
248
  z.object({ kind: z.literal("page_ask"), ...pageAskRequest }),
@@ -279,6 +283,9 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
279
283
  "claude-token-refused",
280
284
  // The account authenticates and has real usage pools, but the org disabled Claude Code for this seat.
281
285
  "claude-not-entitled",
286
+ // Any other provider's account signs in but is refused service outright, for every model (Cursor's
287
+ // `[role_forbidden]` for a non-billable admin): filed against the account, so placement skips it.
288
+ "account-not-entitled",
282
289
  // The provider failed transiently and in-turn retries did not outlast it; the daemon retries on
283
290
  // backoff.
284
291
  "provider-outage",
@@ -21,6 +21,12 @@ describe("another agent's words", () => {
21
21
  expect(agentWordsOf(prompt)).toEqual({ kind: "peer", from: "sharp-shale-htw8", title: "Bun migration", sent: prompt });
22
22
  });
23
23
 
24
+ // 2026-10-17: a child its parent cancelled was reported to it as finished.
25
+ it("says a stopped child was stopped, and still reads it back as a child's report", () => {
26
+ expect(report({ stopped: true }).split("\n", 1)[0]).toMatch(/ was stopped\.$/u);
27
+ expect(agentWordsOf(report({ stopped: true }))).toMatchObject({ kind: "child" });
28
+ });
29
+
24
30
  it("round-trips a child's report, finished or failed", () => {
25
31
  expect(agentWordsOf(report())).toMatchObject({ kind: "child", from: "sub-x7", title: "Port the parser" });
26
32
  expect(agentWordsOf(report()) ?? {}).not.toHaveProperty("failed");
@@ -40,6 +40,8 @@ export interface ChildReportFields {
40
40
  readonly child: string;
41
41
  readonly title: string | undefined;
42
42
  readonly failed: boolean;
43
+ // Stopped by a person or its parent rather than ending by itself: said as such, never as finished.
44
+ readonly stopped?: boolean | undefined;
43
45
  readonly report: string;
44
46
  // Undefined when nothing of the child's work was seen, which is not a verdict.
45
47
  readonly verification: SubagentVerification | undefined;
@@ -70,7 +72,7 @@ const verificationLine = (verification: SubagentVerification | undefined): strin
70
72
  export const childReportPrompt = (fields: ChildReportFields): string => {
71
73
  const verification = verificationLine(fields.verification);
72
74
  return [
73
- `${CHILD_OPENING}${senderOf(fields.child, fields.title)} ${fields.failed ? "failed" : "finished"}.`,
75
+ `${CHILD_OPENING}${senderOf(fields.child, fields.title)} ${fields.failed ? "failed" : fields.stopped === true ? "was stopped" : "finished"}.`,
74
76
  ...(verification === undefined ? [] : [verification]),
75
77
  "",
76
78
  fields.report.trim() === "" ? "(It ended without a closing report.)" : fields.report.trim(),
@@ -81,7 +83,7 @@ export const childReportPrompt = (fields: ChildReportFields): string => {
81
83
  };
82
84
 
83
85
  const PEER_LINE = /^Message from another conversation in this workspace: `([^`]+)`(?: \("(.*)"\))?\.$/u;
84
- const CHILD_LINE = /^Report from a (?:subagent|child agent) you started: `([^`]+)`(?: \("(.*)"\))? (finished|failed)\.$/u;
86
+ const CHILD_LINE = /^Report from a (?:subagent|child agent) you started: `([^`]+)`(?: \("(.*)"\))? (finished|failed|was stopped)\.$/u;
85
87
 
86
88
  const peerOf = (first: string, prompt: string): TranscriptAgentWords | undefined => {
87
89
  const match = first.startsWith(PEER_OPENING) ? PEER_LINE.exec(first) : null;
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+
3
+ // A code review's findings in one shape, whichever harness ran the review: Claude Code's `/code-review` reports them
4
+ // through its ReportFindings tool, Codex's `review/start` ends on a review item whose comments are read into the same
5
+ // rows. The chat draws one card per review (row.review), the same card for either; what the reviewer said around its
6
+ // findings is prose on the bubble, never repeated on the card.
7
+
8
+ // How hard Claude Code's review looked: from a few high-confidence findings (low) to every one it could verify (max).
9
+ export const REVIEW_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
10
+ export const ReviewLevelSchema = z.enum(REVIEW_LEVELS);
11
+ export type ReviewLevel = z.infer<typeof ReviewLevelSchema>;
12
+
13
+ export const ReviewFindingSchema = z.object({
14
+ file: z.string().describe("The file the finding is in, as the reviewer named it: relative to the checkout, or absolute."),
15
+ line: z.number().int().positive().optional().describe("The 1-based line it anchors to."),
16
+ endLine: z.number().int().positive().optional().describe("The last line of the range it covers, where the reviewer named a range."),
17
+ summary: z.string().describe("The defect in one sentence."),
18
+ title: z.string().optional().describe("The finding as a short label, for a row that has no room for the sentence."),
19
+ scenario: z.string().optional().describe("The concrete inputs or state that lead to the wrong output or the crash."),
20
+ body: z.string().optional().describe("The reviewer's own explanation of the finding, where it wrote one beyond the summary."),
21
+ category: z.string().optional().describe("What kind of finding it is: correctness, simplification, efficiency, and so on."),
22
+ verdict: z.enum(["CONFIRMED", "PLAUSIBLE"]).optional().describe("Whether a verification pass reproduced it, or only found it believable."),
23
+ priority: z.number().int().min(0).max(3).optional().describe("How urgent the reviewer rated it: 0 is the most urgent, as Codex's P0 to P3."),
24
+ outcome: z
25
+ .enum(["fixed", "skipped", "no_change_needed"])
26
+ .optional()
27
+ .describe("What happened to it, on a review re-reported after its fixes were applied."),
28
+ });
29
+ export type ReviewFinding = z.infer<typeof ReviewFindingSchema>;
30
+
31
+ export const ReviewFindingsSchema = z.object({
32
+ id: z.string().min(1).describe("The review's own id: a re-report of the same review (its fixes applied) carries it again and redraws the card."),
33
+ source: z.enum(["claude", "codex"]).describe("Which harness's reviewer reported it."),
34
+ level: ReviewLevelSchema.optional().describe("The effort the review ran at, where the reviewer has levels."),
35
+ findings: z.array(ReviewFindingSchema).describe("What it found, most severe first. Empty when nothing survived."),
36
+ superseded: z.boolean().optional().describe("A later report of this review stands further down the conversation, so this one is drawn folded."),
37
+ });
38
+ export type ReviewFindings = z.infer<typeof ReviewFindingsSchema>;
39
+
40
+ // Each reviewer's own cap: ReportFindings takes 32; a Codex review's comments are not capped, so a card says how many
41
+ // were left off rather than drawing a hundred rows.
42
+ export const REVIEW_FINDINGS_MAX = 50;
43
+ // Prose a finding carries is the model's, and unbounded: kept to what a card can draw.
44
+ export const REVIEW_TEXT_MAX = 2000;
@@ -58,6 +58,12 @@ export const HelloSchema = z.object({
58
58
  // `rawFingerprints` is what `shapes` is for a procedure, under the name the rest of the code gives it.
59
59
  rawRoutes: z.array(z.string()).optional(),
60
60
  rawFingerprints: z.record(z.string(), z.string()).optional(),
61
+ // The codings `POST /workspace/upload` and `POST /workspace/upload-archive` decode a request body from
62
+ // (`Content-Encoding`). Absent means a daemon that writes whatever bytes arrive, so the browser sends them plain.
63
+ uploadEncodings: z.array(z.string()).optional(),
64
+ // The codings the voice sockets speak besides PCM when asked (`codec=opus`, schemas/voice.ts). Absent or empty: PCM
65
+ // only, which is every daemon from before the field and one whose Opus binding did not load.
66
+ voiceCodecs: z.array(z.string()).optional(),
61
67
  // `<version>:<newest mtime of the compiled tree>`, as every release since 1.0.0 sends it. The version at its head
62
68
  // is what the browser holds against the oldest daemon it supports (state/daemon-floor.ts), so it stays first.
63
69
  build: z.string().optional(),
@@ -8,6 +8,7 @@ import { HeldEndingSchema, RetryLadderSchema } from "../schemas/turn-break.js";
8
8
  import { HandoffOfferSchema } from "../schemas/providers/handoff.js";
9
9
  import { NoticeCodeSchema } from "./sandbox-notice.js";
10
10
  import { TranscriptAgentNoticeSchema } from "./agent-ui.js";
11
+ import { ReviewFindingsSchema } from "./review.js";
11
12
  import type { ToolCallContent, ToolCallLocation, ToolCallStatus, ToolKind } from "./requests.js";
12
13
  import {
13
14
  browserHelpRequest,
@@ -387,6 +388,8 @@ export const TranscriptRowSchema = z.object({
387
388
  backgroundJob: TranscriptBackgroundJobSchema.optional().describe("The background job this row marks the start of."),
388
389
  // Not a card: a page parks nothing, it is part of what the agent said. On the bubble whose tool call showed it.
389
390
  page: PageSchema.optional().describe("A page the agent showed here, drawn inline."),
391
+ // Not a card either: a review's findings are what the agent said, drawn as one block on the bubble that reported them.
392
+ review: ReviewFindingsSchema.optional().describe("A code review's findings the agent reported here, drawn inline."),
390
393
  pageAsk: TranscriptPageAskSchema.optional().describe("A page the agent asked to be answered on, and what it sent back."),
391
394
  credentialOffer: TranscriptCredentialOfferSchema.optional().describe(
392
395
  "The gated credential this row asked to use, who may release it, and who did.",
@@ -0,0 +1,15 @@
1
+ import { noticeCode } from "./sandbox-notice.js";
2
+ import type { TranscriptRow } from "./transcript.js";
3
+
4
+ // The row a refusal that ran nothing leaves when the conversation's queue holds the words (transcript-fold's heldRow).
5
+ // A turn somebody typed is held in its conversation's queue for another press; a turn that started itself (an
6
+ // automation, a loop, a watch wake) has no typed message and nobody watching, so promising a resend there names a
7
+ // composer the reader is not looking at. Neither version promises a retry: whether one comes is the scheduler's
8
+ // decision, not this row's to state. The code carries the same fact, so the editor's localized line agrees.
9
+ export const undeliveredRow = (said: { readonly message: string; readonly error?: string }, unattended: boolean): TranscriptRow => ({
10
+ role: "notice",
11
+ text: unattended
12
+ ? `${said.message} Nothing ran, and nothing is held: this run started on its own, so there is no message waiting to be sent again.`
13
+ : `${said.message} Your message was not delivered: it is held for you to send again.`,
14
+ noticeCode: noticeCode({ code: "undelivered", params: { ...said, unattended: unattended || undefined } }),
15
+ });
@@ -111,7 +111,8 @@ export const limited = (agent: AgentStanding): boolean => agent.status === "erro
111
111
 
112
112
  // A message held at the door because the sandbox is short of memory (turn-admission): nothing ran and nothing failed,
113
113
  // it waits for a person's "send anyway". The daemon files it as an `error`, so this is read off the code, as `limited`
114
- // is, and the chip and glyph say held rather than error; the lane stays Attention, since the press is the reader's.
114
+ // is, and the chip and glyph say held rather than error; the lane stays Attention, since the press is the reader's,
115
+ // until its words are booked for later (bookingAnswers).
115
116
  export const memoryHeld = (agent: Pick<AgentStanding, "status" | "failureCode">): boolean =>
116
117
  agent.status === "error" && agent.failureCode === "sandbox-memory-low";
117
118
 
@@ -218,13 +219,29 @@ export const writingNow = (agent: AgentStanding): boolean => agent.status === "r
218
219
  // `stopped`/`interrupted` leave a half-written worktree only a new message can carry forward; `stopping` is
219
220
  // counted the moment the press lands rather than once the unwind finishes, since it settles in the same lane
220
221
  // either way; `failed` is the same kind of dead end one step earlier, before a worktree existed. A spent allowance
221
- // counts until something is booked to carry it on (limitScheduled): a resend at the reset, or a move now.
222
+ // counts until something is booked to carry it on (limitScheduled): a resend at the reset, or a move now. Any settled
223
+ // dead end counts until a booking answers it (bookingAnswers).
222
224
  const BLOCKING_ENDINGS: ReadonlySet<AgentStatus | ClientAgentStatus> = new Set(["error", "interrupted", "stopping", "stopped", "failed"]);
223
225
 
224
226
  // A land that broke and still stands: its work is stuck on the branch, whatever the turn after it did, so the card is
225
227
  // the reader's to look at. Not while a turn or a land is under way, which ends in another land of its own.
226
228
  export const landBroken = (agent: AgentStanding): boolean => agent.landFailure !== undefined && !turnInFlight(agent);
227
229
 
230
+ // The settled dead ends a booking can answer. Not `stopping`, which is still unwinding and rests in Attention whatever
231
+ // waits behind it.
232
+ const ANSWERABLE_ENDINGS: ReadonlySet<AgentStatus | ClientAgentStatus> = new Set(["error", "interrupted", "stopped", "failed"]);
233
+
234
+ /**
235
+ * A turn that ended badly, with something booked to go by itself after it: a message for a time, a continuation at a
236
+ * spent allowance's reopen, words held for memory re-timed to that reopen. That booking is the person's answer to the
237
+ * ending, or the sandbox's on their standing word, so the next move is the sandbox's, at the booking, and the ending no
238
+ * longer calls the reader: the card is Active, and its corner says when it goes. Before 2026-10-18 only the sandbox's own
239
+ * booked resend after a spent allowance (limitScheduled) answered an ending, so an expired account's card stayed in
240
+ * Attention under a clock already saying "sends Sun 05:00", the reader's answer to it booked and not counted. Answers
241
+ * only the ending: an ask (a plan, a question, a permission, a land only the reader can clear) still calls them.
242
+ */
243
+ export const bookingAnswers = (agent: AgentStanding): boolean => ANSWERABLE_ENDINGS.has(agent.status) && scheduledSend(agent);
244
+
228
245
  export const blocked = (agent: AgentStanding): boolean =>
229
246
  limitScheduled(agent)
230
247
  ? false
@@ -238,7 +255,7 @@ export const blocked = (agent: AgentStanding): boolean =>
238
255
  (agent.attention.need === true && agent.status !== "running") ||
239
256
  landBroken(agent) ||
240
257
  heldByPrompt(agent) ||
241
- BLOCKING_ENDINGS.has(agent.status);
258
+ (BLOCKING_ENDINGS.has(agent.status) && !bookingAnswers(agent));
242
259
 
243
260
  // The half of `blocked` that is literally waiting on an answer (plan, question, permission, capability), narrower
244
261
  // than `blocked`: dead ends (a failed turn, an unlandable conflict) want looking at but owe the user nothing, so
@@ -273,6 +290,7 @@ export const heardByParent = (child: AgentStanding & { readonly updatedAt: numbe
273
290
  child.status !== "stopping" &&
274
291
  // Booked to run again by itself: not over, whatever its parent made of the pause.
275
292
  !limitScheduled(child) &&
293
+ !bookingAnswers(child) &&
276
294
  !child.attention.question &&
277
295
  !child.attention.conflict &&
278
296
  !landBroken(child) &&
@@ -258,6 +258,18 @@ test("a refusal stands until a later reading contradicts it, and never past its
258
258
  expect(refusalVerdict(refusal({ kind: "limit", account: "a" }), [])).toBe("standing");
259
259
  });
260
260
 
261
+ // 2026-10-17: Codex's team credit ran out while its weekly pool read 57% on the shared credential.
262
+ test("an allowance no reading measures stands against every reading, until the provider's own reset", () => {
263
+ const room = at(Date.now() + 60_000, window({ kind: "seven_day", utilization: 43 }));
264
+ const credit = refusal({ kind: "limit", unmetered: true, at: Date.now(), resetsAt: Math.floor(Date.now() / 1000) + 3_600 });
265
+ expect(refusalVerdict(credit, [{ account: "a", usage: room }, { account: "b", usage: room }])).toBe("standing");
266
+ expect(refusalVerdict({ ...credit, resetsAt: Math.floor(Date.now() / 1000) - 1 }, [{ account: "a", usage: room }, { account: "b", usage: room }])).toBe(
267
+ "answered",
268
+ );
269
+ expect(refusalVerdict({ ...credit, resetsAt: undefined }, [{ account: "a", usage: room }])).toBe("standing");
270
+ });
271
+
272
+
261
273
  // Codex's usage endpoint kept reading room while every turn was refused "try again at 3:38 PM": the provider's own
262
274
  // instant is what ends the refusal, where it is known which account it refused.
263
275
  test("a spent allowance with the provider's own reset stands until that instant, whatever a later reading says", () => {
@@ -188,9 +188,10 @@ export interface ServiceFacts {
188
188
 
189
189
  // A spent allowance is over once a reading with room lands after it; a refused credential once any reading does (the
190
190
  // token worked). Nothing a reading says answers an entitlement refusal: a seatless account authenticates and reads fine.
191
+ // Nor does any reading answer an allowance it does not measure (`unmetered`: an admin's credit).
191
192
  const answers = (refusal: ProviderRefusal, facts: ServiceFacts): boolean => {
192
193
  const measuredAt = facts.usage?.measuredAt;
193
- if (refusal.kind === "entitlement" || measuredAt === undefined || measuredAt <= refusal.at) {
194
+ if (refusal.kind === "entitlement" || refusal.unmetered === true || measuredAt === undefined || measuredAt <= refusal.at) {
194
195
  return false;
195
196
  }
196
197
  if (refusal.kind === "auth") {
@@ -218,7 +219,7 @@ export const refusalVerdict = (
218
219
  // only one), its own words outrank a usage endpoint that kept showing room while it refused: over at that instant,
219
220
  // and not before. A nameless refusal over several accounts is still answered by any of them reading room, since the
220
221
  // translator benches the one it refused and routes on.
221
- if (refusal.kind === "limit" && refusal.resetsAt !== undefined && (refusal.account !== undefined || accounts.length <= 1)) {
222
+ if (refusal.kind === "limit" && refusal.resetsAt !== undefined && (refusal.account !== undefined || accounts.length <= 1 || refusal.unmetered === true)) {
222
223
  return refusal.resetsAt * 1000 <= now ? "answered" : "standing";
223
224
  }
224
225
  return (refusal.account === undefined ? accounts : named).some((facts) => answers(refusal, facts)) ? "answered" : "standing";
@@ -152,6 +152,19 @@
152
152
  "type"
153
153
  ],
154
154
  "type": "object"
155
+ },
156
+ {
157
+ "description": "Every binary message after this one is the pane's bytes deflated, for a browser that asked with `deflate=1` in\nthe socket's query: its unpacked length (4 bytes, big-endian), then raw deflate (RFC 1951) through a sync flush,\nall of one socket's messages one deflate stream. Sent first, before any pane bytes; a browser that never sees it\nreads binary messages as the raw bytes they always were, which is what an older netd sends whatever was asked.",
158
+ "properties": {
159
+ "type": {
160
+ "const": "deflate",
161
+ "type": "string"
162
+ }
163
+ },
164
+ "required": [
165
+ "type"
166
+ ],
167
+ "type": "object"
155
168
  }
156
169
  ],
157
170
  "title": "TerminalServerMessage"
@@ -54,7 +54,7 @@ export type TerminalClientMessage = { "type": "input", data: string, } | { "type
54
54
  /**
55
55
  * What a terminal socket sends as JSON text; the pane's own bytes travel as binary messages.
56
56
  */
57
- export type TerminalServerMessage = { "type": "grid", cols: number, rows: number, } | { "type": "exit", code: number, reason?: string, } | { "type": "pong" };
57
+ export type TerminalServerMessage = { "type": "grid", cols: number, rows: number, } | { "type": "exit", code: number, reason?: string, } | { "type": "pong" } | { "type": "deflate" };
58
58
 
59
59
  /**
60
60
  * Where netd's tunnel stands.
@@ -42,8 +42,10 @@ const OPEN = 1;
42
42
  // Structural interface a browser's WebSocket, node's global one, and a test's fake all satisfy without a cast.
43
43
  export interface SocketLike {
44
44
  readonly readyState: number;
45
- /* `message` is here for the watchdog alone, which needs no more than the fact that one arrived. */
46
- addEventListener(type: "open" | "close" | "error" | "message", listener: (event: { readonly code?: number }) => void): void;
45
+ /* `message` is here for the watchdog alone, which needs no more than the fact that one arrived. Two signatures, so a
46
+ socket typing its listeners per event (ws's) satisfies it as well as one with a single listener type. */
47
+ addEventListener(type: "close", listener: (event: { readonly code?: number }) => void): void;
48
+ addEventListener(type: "open" | "error" | "message", listener: () => void): void;
47
49
  send(data: string): void;
48
50
  close(code?: number, reason?: string): void;
49
51
  }
@@ -40,6 +40,9 @@ export const RAW_ROUTES = {
40
40
  "GET /workspace/media": { auth: "door", guest: true, lane: "bulk" },
41
41
  // Navigated to for a ZIP of a selection, so no header either: the ticket it carries names the selection.
42
42
  "GET /workspace/download": { auth: "door", guest: true, lane: "bulk" },
43
+ // A previewed web page and the files it names, loaded by a frame, which sends no header: the ticket is the path's
44
+ // first segment, so the page's relative addresses carry it, and it is checked in the handler.
45
+ "GET /workspace/preview/{ticket}/*": { auth: "door", guest: true },
43
46
  // An attachment is part of the message it rides with; any other target edits the shared tree.
44
47
  "POST /workspace/upload": { floor: "writer", attachmentFloor: "collaborator", lane: "bulk" },
45
48
  "POST /workspace/upload-diff": {},
@@ -533,6 +533,12 @@ export const QueuedMessageSchema = z.object({
533
533
  after: ConversationIdSchema.optional().describe(
534
534
  "The conversation whose finished work must land before this message goes out by itself, in place of `until`.",
535
535
  ),
536
+ continues: z
537
+ .literal(true)
538
+ .optional()
539
+ .describe(
540
+ "A continuation, not words: when it goes, the conversation carries on from where its last turn stopped, as `agent.run` with `continues` does, and nothing is added to it as anybody's message. `text` is empty. A turn that starts in the conversation before it goes makes it moot, and it is dropped.",
541
+ ),
536
542
  });
537
543
  export type QueuedMessage = z.infer<typeof QueuedMessageSchema>;
538
544
  // Why a queue holds its messages rather than letting them go when the conversation is free.
package/src/schemas/ci.ts CHANGED
@@ -164,12 +164,27 @@ export type MainFailureDecision = z.infer<typeof MainFailureDecisionSchema>;
164
164
 
165
165
  // A main-line branch while its CI fails, as the daemon's one fix agent on it sees it (ci/main-fixer.ts): from the first
166
166
  // job that failed until a later run of every workflow that failed on it passes, whatever else failed along the way.
167
+ // One workflow failing on a main-line branch, where a push starts several (GitHub): a later run of it passing takes it
168
+ // off the failure, so a pass of any other says nothing of it. A scheduled one (a nightly) is the case that needs
169
+ // spelling out: a push does not run it, and the board's own runs may all pass while it still fails.
170
+ export const CiMainFailureWorkflowSchema = z.object({
171
+ name: z.string().describe("The workflow, by the name the forge gives it. `ci` on a forge that runs one pipeline per commit."),
172
+ runId: z.number().describe("Its newest run that failed."),
173
+ jobs: z.array(z.string()).describe("Its jobs failing now, by name."),
174
+ });
175
+ export type CiMainFailureWorkflow = z.infer<typeof CiMainFailureWorkflowSchema>;
167
176
  export const CiMainFailureSchema = z.object({
168
177
  repo: z.string().describe("Which workspace repository."),
169
178
  branch: z.string().describe("Which main-line branch."),
170
179
  since: z.number().describe("When its first job failed, in milliseconds."),
171
180
  runId: z.number().describe("The newest run that failed on it."),
172
181
  jobs: z.array(z.string()).describe("The jobs failing on it now, by name: the newest failed run's failures, and any that failed since."),
182
+ workflows: z
183
+ .array(CiMainFailureWorkflowSchema)
184
+ .optional()
185
+ .describe(
186
+ "Each workflow failing on it, in the order they began failing, with its newest failed run and its jobs: the branch passes again only once a later run of every one of them passes. Absent from a daemon from before workflows were told apart.",
187
+ ),
173
188
  fixer: z
174
189
  .string()
175
190
  .optional()
@@ -1,4 +1,4 @@
1
- import { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, devRebuildLogPath, readDevRebuildLog } from "./devices.js";
1
+ import { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, devRebuildLogPath, readDevRebuildLog } from "./dev-rebuild-log.js";
2
2
 
3
3
  // The one account a detached rebuild gives of itself. The daemon builds the shell that writes this text and the browser
4
4
  // draws what it means, so the grammar is pinned here, where both of them read it.
@@ -0,0 +1,58 @@
1
+ // What a detached `dev-rebuild` leaves behind on the machine that runs it, and how `dev-rebuild-log` reads it back.
2
+ // devices.ts re-exports all of it beside the device commands that name it.
3
+
4
+ // WHERE A DETACHED REBUILD REPORTS ITSELF. `dev-rebuild` returns the moment the build is under way and nothing streams
5
+ // back from it — a cold build outruns any timeout this door has, and the container swap at the end kills the daemon that
6
+ // would have read the answer — so the build writes here instead, in the folder ic logs its own recreates into, and
7
+ // `dev-rebuild-log` reads it back. Spelled once, here, because the daemon builds both command lines from it and the
8
+ // browser names the same path to a reader whose rebuild never came back.
9
+ export const devRebuildLogPath = (slug: string): string => `~/.intentic/logs/dev-rebuild-${slug}.log`;
10
+
11
+ // The build's own shell appends this when the build ends, with its exit status: since the log outlives the daemon, the
12
+ // container and the page that started it, this mark is the only thing that can say a rebuild is OVER rather than slow.
13
+ export const DEV_REBUILD_EXIT_MARK = "@intentic-rebuild-exit";
14
+ // `dev-rebuild-log` prints this first, then how many seconds it is since the log last grew — `-` when there is no log on
15
+ // that machine at all. A running build goes quiet for a minute at a time inside a docker layer; a build whose machine
16
+ // slept never writes its exit mark, and only this tells the two apart.
17
+ export const DEV_REBUILD_QUIET_MARK = "@intentic-rebuild-quiet";
18
+
19
+ export interface DevRebuildLog {
20
+ /** Nothing has ever rebuilt this sandbox from a checkout on that machine. */
21
+ readonly missing: boolean;
22
+ /** Seconds since the log last grew; undefined when the machine's `stat` wouldn't say. */
23
+ readonly quietFor: number | undefined;
24
+ /** The build's own exit status, present only once it has ended. */
25
+ readonly exitCode: number | undefined;
26
+ /** The tail as the machine printed it, both marks removed. */
27
+ readonly lines: readonly string[];
28
+ }
29
+
30
+ // Reads `dev-rebuild-log`'s stdout. Shared rather than parsed at the reader, so the shell line that produces this and the
31
+ // card that draws it cannot drift apart. Unparseable is never an error: an answer with no marks at all is simply a log
32
+ // with nothing to say yet.
33
+ const markValue = (line: string, mark: string): string | undefined => (line.startsWith(`${mark} `) ? line.slice(mark.length + 1).trim() : undefined);
34
+ const wholeNumber = (value: string): number | undefined => (/^\d+$/.test(value) ? Number(value) : undefined);
35
+
36
+ export const readDevRebuildLog = (text: string): DevRebuildLog => {
37
+ let missing = false;
38
+ let quietFor: number | undefined;
39
+ let exitCode: number | undefined;
40
+ const lines: string[] = [];
41
+ for (const line of text.split(/\r?\n/)) {
42
+ const quiet = markValue(line, DEV_REBUILD_QUIET_MARK);
43
+ const exit = markValue(line, DEV_REBUILD_EXIT_MARK);
44
+ if (quiet !== undefined) {
45
+ missing = quiet === "-";
46
+ quietFor = wholeNumber(quiet);
47
+ } else if (exit !== undefined) {
48
+ // A mark whose status is unreadable still means the build ENDED, and not well.
49
+ exitCode = wholeNumber(exit) ?? 1;
50
+ } else {
51
+ lines.push(line);
52
+ }
53
+ }
54
+ while (lines.at(-1)?.trim() === "") {
55
+ lines.pop();
56
+ }
57
+ return { missing, quietFor, exitCode, lines };
58
+ };
@@ -266,61 +266,9 @@ export type DeviceCommand = z.infer<typeof DeviceCommandSchema>;
266
266
  export const DeviceSyncSwitchSchema = DeviceCommandSchema.exclude(["dev-restart", "dev-rebuild", "dev-rebuild-log", "sync-install", "sync-clean"]);
267
267
  export type DeviceSyncSwitch = z.infer<typeof DeviceSyncSwitchSchema>;
268
268
 
269
- // WHERE A DETACHED REBUILD REPORTS ITSELF. `dev-rebuild` returns the moment the build is under way and nothing streams
270
- // back from it — a cold build outruns any timeout this door has, and the container swap at the end kills the daemon that
271
- // would have read the answer — so the build writes here instead, in the folder ic logs its own recreates into, and
272
- // `dev-rebuild-log` reads it back. Spelled once, here, because the daemon builds both command lines from it and the
273
- // browser names the same path to a reader whose rebuild never came back.
274
- export const devRebuildLogPath = (slug: string): string => `~/.intentic/logs/dev-rebuild-${slug}.log`;
275
-
276
- // The build's own shell appends this when the build ends, with its exit status: since the log outlives the daemon, the
277
- // container and the page that started it, this mark is the only thing that can say a rebuild is OVER rather than slow.
278
- export const DEV_REBUILD_EXIT_MARK = "@intentic-rebuild-exit";
279
- // `dev-rebuild-log` prints this first, then how many seconds it is since the log last grew — `-` when there is no log on
280
- // that machine at all. A running build goes quiet for a minute at a time inside a docker layer; a build whose machine
281
- // slept never writes its exit mark, and only this tells the two apart.
282
- export const DEV_REBUILD_QUIET_MARK = "@intentic-rebuild-quiet";
283
-
284
- export interface DevRebuildLog {
285
- /** Nothing has ever rebuilt this sandbox from a checkout on that machine. */
286
- readonly missing: boolean;
287
- /** Seconds since the log last grew; undefined when the machine's `stat` wouldn't say. */
288
- readonly quietFor: number | undefined;
289
- /** The build's own exit status, present only once it has ended. */
290
- readonly exitCode: number | undefined;
291
- /** The tail as the machine printed it, both marks removed. */
292
- readonly lines: readonly string[];
293
- }
269
+ // Where a detached rebuild reports itself, and the reader of that report (dev-rebuild-log.ts).
270
+ export { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, type DevRebuildLog, devRebuildLogPath, readDevRebuildLog } from "./dev-rebuild-log.js";
294
271
 
295
- // Reads `dev-rebuild-log`'s stdout. Shared rather than parsed at the reader, so the shell line that produces this and the
296
- // card that draws it cannot drift apart. Unparseable is never an error: an answer with no marks at all is simply a log
297
- // with nothing to say yet.
298
- const markValue = (line: string, mark: string): string | undefined => (line.startsWith(`${mark} `) ? line.slice(mark.length + 1).trim() : undefined);
299
- const wholeNumber = (value: string): number | undefined => (/^\d+$/.test(value) ? Number(value) : undefined);
300
-
301
- export const readDevRebuildLog = (text: string): DevRebuildLog => {
302
- let missing = false;
303
- let quietFor: number | undefined;
304
- let exitCode: number | undefined;
305
- const lines: string[] = [];
306
- for (const line of text.split(/\r?\n/)) {
307
- const quiet = markValue(line, DEV_REBUILD_QUIET_MARK);
308
- const exit = markValue(line, DEV_REBUILD_EXIT_MARK);
309
- if (quiet !== undefined) {
310
- missing = quiet === "-";
311
- quietFor = wholeNumber(quiet);
312
- } else if (exit !== undefined) {
313
- // A mark whose status is unreadable still means the build ENDED, and not well.
314
- exitCode = wholeNumber(exit) ?? 1;
315
- } else {
316
- lines.push(line);
317
- }
318
- }
319
- while (lines.at(-1)?.trim() === "") {
320
- lines.pop();
321
- }
322
- return { missing, quietFor, exitCode, lines };
323
- };
324
272
  // Machine's sandbox id (absent = every paired sandbox); must start alphanumeric, else it parses as a CLI flag.
325
273
  export const DeviceSandboxIdSchema = z
326
274
  .string()