@intentic/sandbox-contract 1.244.0 → 1.246.1

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 (211) hide show
  1. package/README.md +17 -1
  2. package/dist/batch-runs.d.ts +32 -0
  3. package/dist/batch-runs.d.ts.map +1 -0
  4. package/dist/batch-runs.js +43 -0
  5. package/dist/batch-runs.js.map +1 -0
  6. package/dist/command-classes.d.ts +6 -3
  7. package/dist/command-classes.d.ts.map +1 -1
  8. package/dist/command-classes.js +43 -18
  9. package/dist/command-classes.js.map +1 -1
  10. package/dist/contracts/{cursor.contract.d.ts → accounts.contract.d.ts} +102 -3
  11. package/dist/contracts/accounts.contract.d.ts.map +1 -0
  12. package/dist/contracts/accounts.contract.js +61 -0
  13. package/dist/contracts/accounts.contract.js.map +1 -0
  14. package/dist/contracts/agents.contract.d.ts +121 -0
  15. package/dist/contracts/agents.contract.d.ts.map +1 -1
  16. package/dist/contracts/agents.contract.js +4 -4
  17. package/dist/contracts/agents.contract.js.map +1 -1
  18. package/dist/contracts/ci.contract.d.ts +2 -0
  19. package/dist/contracts/ci.contract.d.ts.map +1 -1
  20. package/dist/contracts/host.contract.d.ts +35 -0
  21. package/dist/contracts/host.contract.d.ts.map +1 -1
  22. package/dist/contracts/host.contract.js +3 -2
  23. package/dist/contracts/host.contract.js.map +1 -1
  24. package/dist/contracts/personas.contract.d.ts +4 -2
  25. package/dist/contracts/personas.contract.d.ts.map +1 -1
  26. package/dist/contracts/runner.contract.d.ts +2 -2
  27. package/dist/contracts/settings.contract.d.ts +2 -51
  28. package/dist/contracts/settings.contract.d.ts.map +1 -1
  29. package/dist/contracts/system.contract.d.ts +52 -2
  30. package/dist/contracts/system.contract.d.ts.map +1 -1
  31. package/dist/contracts/system.contract.js +26 -17
  32. package/dist/contracts/system.contract.js.map +1 -1
  33. package/dist/definition.d.ts +4 -8
  34. package/dist/definition.d.ts.map +1 -1
  35. package/dist/documents.d.ts +0 -1
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +1 -2
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed.d.ts +23 -0
  40. package/dist/embed.d.ts.map +1 -0
  41. package/dist/embed.js +84 -0
  42. package/dist/embed.js.map +1 -0
  43. package/dist/events.d.ts +21 -0
  44. package/dist/events.d.ts.map +1 -1
  45. package/dist/events.js +5 -2
  46. package/dist/events.js.map +1 -1
  47. package/dist/history-state.d.ts.map +1 -1
  48. package/dist/history-state.js +2 -0
  49. package/dist/history-state.js.map +1 -1
  50. package/dist/index.d.ts +354 -335
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +5 -13
  53. package/dist/index.js.map +1 -1
  54. package/dist/peer-dial.d.ts +33 -0
  55. package/dist/peer-dial.d.ts.map +1 -0
  56. package/dist/peer-dial.js +79 -0
  57. package/dist/peer-dial.js.map +1 -0
  58. package/dist/peer-mcp-server.d.ts +36 -0
  59. package/dist/peer-mcp-server.d.ts.map +1 -0
  60. package/dist/peer-mcp-server.js +71 -0
  61. package/dist/peer-mcp-server.js.map +1 -0
  62. package/dist/provider-specs.d.ts +38 -20
  63. package/dist/provider-specs.d.ts.map +1 -1
  64. package/dist/provider-specs.js +39 -13
  65. package/dist/provider-specs.js.map +1 -1
  66. package/dist/quick-model.d.ts.map +1 -1
  67. package/dist/quick-model.js +2 -1
  68. package/dist/quick-model.js.map +1 -1
  69. package/dist/runtime-state.d.ts +1 -1
  70. package/dist/runtime-state.js +1 -1
  71. package/dist/runtime-state.js.map +1 -1
  72. package/dist/safety-policy.d.ts +12 -3
  73. package/dist/safety-policy.d.ts.map +1 -1
  74. package/dist/safety-policy.js +30 -5
  75. package/dist/safety-policy.js.map +1 -1
  76. package/dist/schemas/agent.d.ts +6 -6
  77. package/dist/schemas/agent.d.ts.map +1 -1
  78. package/dist/schemas/agent.js +6 -2
  79. package/dist/schemas/agent.js.map +1 -1
  80. package/dist/schemas/agents.d.ts +42 -0
  81. package/dist/schemas/agents.d.ts.map +1 -1
  82. package/dist/schemas/agents.js +25 -4
  83. package/dist/schemas/agents.js.map +1 -1
  84. package/dist/schemas/automations.d.ts +11 -2
  85. package/dist/schemas/automations.d.ts.map +1 -1
  86. package/dist/schemas/automations.js +1 -1
  87. package/dist/schemas/automations.js.map +1 -1
  88. package/dist/schemas/ci.d.ts +6 -0
  89. package/dist/schemas/ci.d.ts.map +1 -1
  90. package/dist/schemas/ci.js +3 -2
  91. package/dist/schemas/ci.js.map +1 -1
  92. package/dist/schemas/context.d.ts +30 -0
  93. package/dist/schemas/context.d.ts.map +1 -0
  94. package/dist/schemas/context.js +34 -0
  95. package/dist/schemas/context.js.map +1 -0
  96. package/dist/schemas/{computers.d.ts → devices.d.ts} +155 -61
  97. package/dist/schemas/devices.d.ts.map +1 -0
  98. package/dist/schemas/devices.js +157 -0
  99. package/dist/schemas/devices.js.map +1 -0
  100. package/dist/schemas/hosts.d.ts +12 -0
  101. package/dist/schemas/hosts.d.ts.map +1 -1
  102. package/dist/schemas/hosts.js +1 -0
  103. package/dist/schemas/hosts.js.map +1 -1
  104. package/dist/schemas/issues.d.ts +0 -5
  105. package/dist/schemas/issues.d.ts.map +1 -1
  106. package/dist/schemas/issues.js +0 -1
  107. package/dist/schemas/issues.js.map +1 -1
  108. package/dist/schemas/personas.d.ts +5 -3
  109. package/dist/schemas/personas.d.ts.map +1 -1
  110. package/dist/schemas/personas.js +3 -2
  111. package/dist/schemas/personas.js.map +1 -1
  112. package/dist/schemas/provider-oauth.d.ts +48 -16
  113. package/dist/schemas/provider-oauth.d.ts.map +1 -1
  114. package/dist/schemas/provider-oauth.js +22 -20
  115. package/dist/schemas/provider-oauth.js.map +1 -1
  116. package/dist/schemas/settings.d.ts +1 -52
  117. package/dist/schemas/settings.d.ts.map +1 -1
  118. package/dist/schemas/settings.js +8 -11
  119. package/dist/schemas/settings.js.map +1 -1
  120. package/dist/schemas/terminal.js +9 -9
  121. package/dist/schemas/terminal.js.map +1 -1
  122. package/dist/schemas/usage.d.ts +0 -2
  123. package/dist/schemas/usage.d.ts.map +1 -1
  124. package/dist/schemas/usage.js +0 -2
  125. package/dist/schemas/usage.js.map +1 -1
  126. package/dist/shell-regions.d.ts +4 -0
  127. package/dist/shell-regions.d.ts.map +1 -0
  128. package/dist/shell-regions.js +156 -0
  129. package/dist/shell-regions.js.map +1 -0
  130. package/dist/workspace-state.d.ts +15 -0
  131. package/dist/workspace-state.d.ts.map +1 -1
  132. package/dist/workspace-state.js +21 -5
  133. package/dist/workspace-state.js.map +1 -1
  134. package/package.json +48 -4
  135. package/src/agent-catalog.ts +1 -1
  136. package/src/arrival.ts +3 -3
  137. package/src/batch-runs.test.ts +167 -0
  138. package/src/batch-runs.ts +181 -0
  139. package/src/command-classes.test.ts +195 -71
  140. package/src/command-classes.ts +148 -46
  141. package/src/contracts/accounts.contract.ts +94 -0
  142. package/src/contracts/agents.contract.ts +4 -3
  143. package/src/contracts/exit.contract.ts +2 -2
  144. package/src/contracts/host.contract.ts +17 -5
  145. package/src/contracts/settings.contract.ts +1 -1
  146. package/src/contracts/system.contract.ts +43 -24
  147. package/src/contracts/vpn.contract.ts +2 -2
  148. package/src/documents.test.ts +2 -1
  149. package/src/documents.ts +7 -11
  150. package/src/embed.test.ts +68 -0
  151. package/src/embed.ts +164 -0
  152. package/src/events.ts +30 -3
  153. package/src/history-state.ts +12 -3
  154. package/src/host-protocol.ts +2 -2
  155. package/src/index.ts +6 -14
  156. package/src/model-order.ts +1 -1
  157. package/src/peer-dial.test.ts +203 -0
  158. package/src/peer-dial.ts +163 -0
  159. package/src/peer-mcp-server.test.ts +104 -0
  160. package/src/peer-mcp-server.ts +144 -0
  161. package/src/provider-specs.test.ts +44 -17
  162. package/src/provider-specs.ts +147 -67
  163. package/src/quick-model.ts +10 -3
  164. package/src/routes.test.ts +6 -3
  165. package/src/runner-protocol.ts +1 -1
  166. package/src/runtime-state.ts +2 -2
  167. package/src/safety-policy.test.ts +88 -0
  168. package/src/safety-policy.ts +84 -14
  169. package/src/schemas/agent.ts +41 -13
  170. package/src/schemas/agents.ts +65 -4
  171. package/src/schemas/automations.ts +6 -4
  172. package/src/schemas/capabilities.ts +4 -4
  173. package/src/schemas/ci.ts +23 -6
  174. package/src/schemas/context.ts +87 -0
  175. package/src/schemas/{computers.ts → devices.ts} +201 -109
  176. package/src/schemas/hosts.ts +5 -1
  177. package/src/schemas/issues.ts +0 -4
  178. package/src/schemas/personas.ts +8 -3
  179. package/src/schemas/provider-oauth.ts +49 -52
  180. package/src/schemas/settings.ts +30 -54
  181. package/src/schemas/terminal.ts +12 -12
  182. package/src/schemas/usage.ts +3 -27
  183. package/src/schemas/version-seam.test.ts +0 -1
  184. package/src/shell-regions.ts +289 -0
  185. package/src/versions.ts +2 -2
  186. package/src/webext-links.ts +2 -2
  187. package/src/webext-protocol.ts +2 -2
  188. package/src/workspace-state.test.ts +55 -1
  189. package/src/workspace-state.ts +76 -11
  190. package/dist/contracts/claude.contract.d.ts +0 -91
  191. package/dist/contracts/claude.contract.d.ts.map +0 -1
  192. package/dist/contracts/claude.contract.js +0 -50
  193. package/dist/contracts/claude.contract.js.map +0 -1
  194. package/dist/contracts/cursor.contract.d.ts.map +0 -1
  195. package/dist/contracts/cursor.contract.js +0 -50
  196. package/dist/contracts/cursor.contract.js.map +0 -1
  197. package/dist/contracts/grok.contract.d.ts +0 -36
  198. package/dist/contracts/grok.contract.d.ts.map +0 -1
  199. package/dist/contracts/grok.contract.js +0 -31
  200. package/dist/contracts/grok.contract.js.map +0 -1
  201. package/dist/contracts/keys.contract.d.ts +0 -81
  202. package/dist/contracts/keys.contract.d.ts.map +0 -1
  203. package/dist/contracts/keys.contract.js +0 -51
  204. package/dist/contracts/keys.contract.js.map +0 -1
  205. package/dist/schemas/computers.d.ts.map +0 -1
  206. package/dist/schemas/computers.js +0 -134
  207. package/dist/schemas/computers.js.map +0 -1
  208. package/src/contracts/claude.contract.ts +0 -71
  209. package/src/contracts/cursor.contract.ts +0 -74
  210. package/src/contracts/grok.contract.ts +0 -41
  211. package/src/contracts/keys.contract.ts +0 -79
@@ -0,0 +1,167 @@
1
+ import { STATE_DIR } from "@intentic/constants";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ type BatchRunKind,
5
+ batchConversationId,
6
+ batchItemDir,
7
+ batchReportingClause,
8
+ batchResultPath,
9
+ batchRunDir,
10
+ batchRunIdAt,
11
+ batchRunManifestPath,
12
+ batchRunPrefix,
13
+ batchTurnBody,
14
+ parseBatchFile,
15
+ } from "./batch-runs.js";
16
+ import { ConversationIdSchema } from "./schemas/agent.js";
17
+
18
+ // The two real layouts this substrate had to fit without moving anything already on disk: one that fans out
19
+ // over items under `records/artifacts`, and one where a run IS the item, under `records/chores`.
20
+ const FANNED: BatchRunKind = { runsDir: `records/artifacts/acceptance`, prefix: `xt`, scanRuns: 10 };
21
+ const SINGLE: BatchRunKind = { runsDir: `records/chores/runs`, prefix: `mt`, scanRuns: 30 };
22
+
23
+ describe(`batch run ids`, () => {
24
+ it(`sorts by the moment it was minted`, () => {
25
+ const earlier = batchRunIdAt(Date.parse(`2026-09-04T00:00:00Z`));
26
+ const later = batchRunIdAt(Date.parse(`2026-09-05T00:00:00Z`));
27
+ expect([earlier < later, earlier.startsWith(`r`)]).toEqual([true, true]);
28
+ });
29
+
30
+ /* Sorting has to survive a base-36 digit boundary, which is what an unpadded `toString(36)` gets wrong:
31
+ * the smaller number is the shorter string, and the shorter string sorts first only by luck. */
32
+ it(`still sorts when two moments straddle a digit boundary`, () => {
33
+ const boundary = 36 ** 7;
34
+ expect(batchRunIdAt(boundary - 1) < batchRunIdAt(boundary)).toBe(true);
35
+ });
36
+
37
+ /* The drift that made this shared. A surface where one run IS one item mints several inside a millisecond
38
+ * ("run this chore in every repository"), and the copy without a counter handed them all the same id. */
39
+ it(`does not repeat inside one millisecond`, () => {
40
+ const minted = new Set(Array.from({ length: 50 }, () => batchRunIdAt(1_700_000_000_000)));
41
+ expect(minted.size).toBe(50);
42
+ });
43
+ });
44
+
45
+ describe(`batch conversation ids`, () => {
46
+ it(`carries the kind's prefix, so a run joins the fleet by filtering GET /agents`, () => {
47
+ const id = batchConversationId(FANNED, `r5k2`, `checkout-flow`);
48
+ expect([id, id.startsWith(batchRunPrefix(FANNED))]).toEqual([`xt-r5k2-checkout-flow`, true]);
49
+ });
50
+
51
+ it(`names a single-item run by its run id alone`, () => {
52
+ expect(batchConversationId(SINGLE, `r5k2a`)).toBe(`mt-r5k2a`);
53
+ });
54
+
55
+ /* The run id is how a finished card is attributed back to its run, so the ITEM is what gets cut. Measured
56
+ * against the real schema rather than a copy of its regex: the cap exists because that schema enforces it. */
57
+ it(`cuts the item, never the run id, and still satisfies ConversationIdSchema`, () => {
58
+ const runId = batchRunIdAt(1_700_000_000_000);
59
+ const id = batchConversationId(FANNED, runId, `x`.repeat(200));
60
+ expect([id.startsWith(`xt-${runId}-`), ConversationIdSchema.safeParse(id).success]).toEqual([true, true]);
61
+ });
62
+
63
+ // A cut that lands on the separator would leave `xt-r5k2-`, which ConversationIdSchema refuses.
64
+ it(`leaves no trailing separator when the cut lands on one`, () => {
65
+ const runId = `r${`z`.repeat(58)}`;
66
+ const id = batchConversationId(FANNED, runId, `story`);
67
+ expect([id.endsWith(`-`), ConversationIdSchema.safeParse(id).success]).toEqual([false, true]);
68
+ });
69
+ });
70
+
71
+ describe(`where a run keeps its files`, () => {
72
+ it(`puts every run under the state dir, in the kind's own directory`, () => {
73
+ expect(batchRunManifestPath(FANNED, `r5k2`)).toBe(`${STATE_DIR}/records/artifacts/acceptance/r5k2/run.json`);
74
+ });
75
+
76
+ it(`gives a fanned-out item a directory of its own`, () => {
77
+ expect(batchResultPath(FANNED, `r5k2`, `checkout-flow`)).toBe(`${STATE_DIR}/records/artifacts/acceptance/r5k2/checkout-flow/result.json`);
78
+ });
79
+
80
+ // A run whose item is the run writes beside its manifest rather than one pointless level down: the layout
81
+ // maintenance already has on disk.
82
+ it(`writes a single-item run's result beside its manifest`, () => {
83
+ expect([batchResultPath(SINGLE, `r5k2a`), batchItemDir(SINGLE, `r5k2a`)]).toEqual([
84
+ `${STATE_DIR}/records/chores/runs/r5k2a/result.json`,
85
+ batchRunDir(SINGLE, `r5k2a`),
86
+ ]);
87
+ });
88
+ });
89
+
90
+ describe(`reading a file an agent may still be writing`, () => {
91
+ const shape = (value: Record<string, unknown>): { outcome: string } | undefined =>
92
+ typeof value[`outcome`] === `string` ? { outcome: value[`outcome`] } : undefined;
93
+
94
+ it(`reads a well-formed file through the caller's shape`, () => {
95
+ expect(parseBatchFile(`{"outcome":"acted"}`, shape)).toEqual({ outcome: `acted` });
96
+ });
97
+
98
+ /* All three failures are the same answer — undefined — because one bad directory must not blank a whole
99
+ * history, and a half-written file is ordinary here rather than exceptional. */
100
+ it(`skips a file that is truncated, is not an object, or does not fit the shape`, () => {
101
+ expect([parseBatchFile(`{"outcome":`, shape), parseBatchFile(`"acted"`, shape), parseBatchFile(`{"summary":"x"}`, shape)]).toEqual([
102
+ undefined,
103
+ undefined,
104
+ undefined,
105
+ ]);
106
+ });
107
+
108
+ it(`does not read null as an object`, () => {
109
+ expect(parseBatchFile(`null`, shape)).toBeUndefined();
110
+ });
111
+ });
112
+
113
+ describe(`what the agent is told`, () => {
114
+ const clause = batchReportingClause({
115
+ path: batchResultPath(SINGLE, `r5k2a`),
116
+ fields: `{"outcome": "acted" | "clean", "summary": "<one or two sentences>"}`,
117
+ outcomes: `Use "clean" when the findings did not hold up.`,
118
+ });
119
+
120
+ it(`names the exact path the surface will read back`, () => {
121
+ expect(clause).toContain(batchResultPath(SINGLE, `r5k2a`));
122
+ });
123
+
124
+ /* A turn that concludes there was nothing to do and writes no file is indistinguishable from a turn that
125
+ * died, and the surface has to show the second as an unknown. */
126
+ it(`asks for the file even when there was nothing to do`, () => {
127
+ expect(clause).toContain(`even if you conclude there was nothing to do`);
128
+ });
129
+
130
+ it(`leaves the outcome vocabulary out when the kind has none`, () => {
131
+ expect(batchReportingClause({ path: `p`, fields: `f` })).not.toContain(`clean`);
132
+ });
133
+ });
134
+
135
+ describe(`the turn a run starts`, () => {
136
+ it(`is always isolated and unattended, which is what registers the fleet entry`, () => {
137
+ const body = batchTurnBody({ prompt: `do it`, title: `t`, conversationId: `mt-r5k2a` });
138
+ expect([body[`isolated`], body[`unattended`]]).toEqual([true, true]);
139
+ });
140
+
141
+ it(`caps the title at what the fleet row can show`, () => {
142
+ expect(String(batchTurnBody({ prompt: `p`, title: `t`.repeat(200), conversationId: `c` })[`title`])).toHaveLength(80);
143
+ });
144
+
145
+ it(`carries a pinned model and its tier when the row's caret chose one`, () => {
146
+ const body = batchTurnBody({
147
+ prompt: `p`,
148
+ title: `t`,
149
+ conversationId: `c`,
150
+ pick: { provider: `claude`, model: `opus`, effort: `high` },
151
+ });
152
+ expect([body[`agent`], body[`model`], body[`effort`]]).toEqual([`claude`, `opus`, `high`]);
153
+ });
154
+
155
+ /* ABSENT rather than undefined: the body is serialized to JSON and the daemon's fill step reads a missing
156
+ * key as "the owner's agentRunModels decide", which is not what an explicit null would say. */
157
+ it(`omits the keys a pick did not pin, rather than sending them empty`, () => {
158
+ const body = batchTurnBody({ prompt: `p`, title: `t`, conversationId: `c`, pick: { provider: `claude` } });
159
+ expect(Object.keys(body)).not.toContain(`model`);
160
+ expect(Object.keys(body)).not.toContain(`effort`);
161
+ });
162
+
163
+ it(`sends no model keys at all when nothing was pinned`, () => {
164
+ const keys = Object.keys(batchTurnBody({ prompt: `p`, title: `t`, conversationId: `c` }));
165
+ expect(keys.some((key) => [`agent`, `model`, `effort`].includes(key))).toBe(false);
166
+ });
167
+ });
@@ -0,0 +1,181 @@
1
+ import { STATE_DIR } from "@intentic/constants";
2
+
3
+ /* ONE BATCH RUN ENGINE, for every surface that fans an ISOLATED AGENT TURN out over a set of items and reads
4
+ * the answers back off disk.
5
+ *
6
+ * Three packs had written this separately — acceptance (a run per story), maintenance (a run per chore, per
7
+ * repository) and documentation (a run per package) — and the three copies agreed on every decision that
8
+ * matters and drifted on every detail that does not: `SCAN_RUNS` was 10 in one and 30 in another, one minted
9
+ * run ids with a per-process counter and one without (the one without could collide, and did, the moment a
10
+ * surface started several runs inside a millisecond), and each spelled the reporting clause its own way, so an
11
+ * agent's instructions for where to leave its answer depended on which screen started it.
12
+ *
13
+ * WHY THE CORE OWNS IT rather than one of them exporting it. A substrate is what other packs fire into, and a
14
+ * pack can be switched off: a run engine that stops existing because somebody hid a screen is not an engine.
15
+ * The reverse — a substrate living in one pack — is what produced the three copies, because reaching into
16
+ * `acceptance` for a run id is a dependency no other pack wants and reinventing it is one afternoon.
17
+ * `_extensions/README.md` states the rule; this is the second of the four substrates named there.
18
+ *
19
+ * WHAT A RUN IS, and the part that is load-bearing rather than incidental:
20
+ *
21
+ * • IT IS BACKED BY FILES, never by a store a pack owns. `run.json` is written before the first turn starts
22
+ * and each agent writes its own result beside it. So a run survives archiving the fleet agents, discarding
23
+ * them, closing the browser and rebuilding the image, and a browser that was shut when a turn finished picks
24
+ * the answer up the next time it opens.
25
+ * • ITS CONVERSATION IDS ARE DERIVED, so joining a run to the fleet is a filter over `GET /agents` rather than
26
+ * bookkeeping that can drift. This is why none of these surfaces owns session machinery: the worktree, the
27
+ * live status, the cost, the transcript and the `/agents/<id>` page all already exist.
28
+ * • IT LIVES UNDER `.intentic`, which is outside every repo (the root repo excludes it) and is bound back in
29
+ * SHARED for isolated turns, so an agent writing its result from inside its own worktree writes into the
30
+ * same tree the browser reads: nothing to land, no git noise.
31
+ *
32
+ * The manifest and result SHAPES stay with the packs. A chore's outcome vocabulary and a story's criteria have
33
+ * nothing to say to each other, and a substrate that tried to own both would be a union that grows a field per
34
+ * screen. What is here is what all three do identically: where the files go, how the ids are made, how a
35
+ * half-written file is survived, and what the agent is told about where to leave its answer. */
36
+
37
+ /* WHERE ONE KIND OF RUN KEEPS ITS DIRECTORIES. Taken as the tail rather than composed from a pack id, because
38
+ * the three existing layouts are not uniform and rewriting them would orphan every run already on disk:
39
+ * acceptance keeps runs under `records/artifacts/acceptance`, maintenance under `records/chores/runs`. A path
40
+ * is a fact about a tree that exists, not a naming opportunity. */
41
+ export interface BatchRunKind {
42
+ /* The directory holding this kind's run directories, workspace-relative, under the state dir. */
43
+ readonly runsDir: string;
44
+ /* The conversation-id prefix, two or three characters. Every conversation this kind starts carries it, so a
45
+ * prefix filter over `GET /agents` is the join key and not merely a naming convention. */
46
+ readonly prefix: string;
47
+ /* How many runs deep anything that READS RESULTS goes. A bound on the walk, not on what can be run: only
48
+ * recent runs carry news, and a workspace with hundreds of run directories must not spend a request per
49
+ * item to render a list or light a badge. One number per kind, shared by every reader of that kind, so a
50
+ * badge's idea of "recent" and a list's can never disagree. */
51
+ readonly scanRuns: number;
52
+ }
53
+
54
+ /* The conversation id's own regex is `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$` — it lands in branch names and in
55
+ * paths — so this is a hard ceiling rather than a style choice. */
56
+ const CONVERSATION_ID_MAX = 64;
57
+
58
+ /* `r` + a base-36 millisecond, zero-padded, + a per-process counter: sortable, ~10 characters, and readable
59
+ * enough to match a directory to a moment. The clock is taken from the caller so this stays pure and testable.
60
+ *
61
+ * THE PADDING IS WHAT MAKES "SORTABLE" TRUE. Both copies this replaces claimed it and neither had it: a base-36
62
+ * number is shorter when it is smaller, so a plain `toString(36)` sorts `r1a` after `rzz` the moment two ids
63
+ * straddle a digit boundary. Today's milliseconds are all eight digits so nothing has gone wrong yet, and
64
+ * nothing will until 2059 — which is exactly the kind of latent boundary that is cheap to remove now and
65
+ * expensive to find later. Padded to the same width, the ids on disk are the length they already were.
66
+ *
67
+ * THE COUNTER IS NOT OPTIONAL, the other drift worth naming. A surface that fans out over items can get away
68
+ * without one, because its ids differ by item; a surface where one run IS one item cannot, and "run this chore
69
+ * in every repository" starts several inside the same millisecond. Both kinds share this function, so the
70
+ * safe answer is the only answer, and it costs the id one character. */
71
+ const TIME_DIGITS = 8;
72
+ let sequence = 0;
73
+ export const batchRunIdAt = (epochMs: number): string => `r${epochMs.toString(36).padStart(TIME_DIGITS, `0`)}${(sequence++).toString(36)}`;
74
+
75
+ /* The fleet conversation id for one item of one run, or for a run that is a single item (omit `item`).
76
+ *
77
+ * THE RUN ID SURVIVES TRUNCATION and the item is what gets cut, because the run id is how a card is attributed
78
+ * back to its run: lose that and a finished turn belongs to nothing. Callers that fan out must already have
79
+ * made their item slugs unique by suffixing, and the suffix sits at the end — exactly where the cut lands — so
80
+ * uniqueness holds only while the cut leaves it. In practice nothing is close: slugs are capped at 40 and run
81
+ * ids are ~10, well inside 64. A trailing separator left by the cut is trimmed, because `xt-r5k2-` is a
82
+ * conversation id the regex above would refuse. */
83
+ export const batchConversationId = (kind: BatchRunKind, runId: string, item?: string): string =>
84
+ `${kind.prefix}-${runId}${item === undefined ? `` : `-${item}`}`.slice(0, CONVERSATION_ID_MAX).replace(/[-_]+$/u, ``);
85
+
86
+ // Every conversation one kind starts, for the prefix filter over `GET /agents` that joins a run to the fleet.
87
+ export const batchRunPrefix = (kind: BatchRunKind): string => `${kind.prefix}-`;
88
+
89
+ // The directory holding one kind's run directories, workspace-relative. What a listing is asked for.
90
+ export const batchRunsDir = (kind: BatchRunKind): string => `${STATE_DIR}/${kind.runsDir}`;
91
+ export const batchRunDir = (kind: BatchRunKind, runId: string): string => `${batchRunsDir(kind)}/${runId}`;
92
+ export const batchRunManifestPath = (kind: BatchRunKind, runId: string): string => `${batchRunDir(kind, runId)}/run.json`;
93
+
94
+ /* Where one item of a run leaves its files. A run whose items are the run itself (`item` omitted) writes
95
+ * straight into the run directory, which is what maintenance already does and what keeps its `result.json`
96
+ * beside its `run.json` rather than one pointless level down. */
97
+ export const batchItemDir = (kind: BatchRunKind, runId: string, item?: string): string =>
98
+ item === undefined ? batchRunDir(kind, runId) : `${batchRunDir(kind, runId)}/${item}`;
99
+ export const batchResultPath = (kind: BatchRunKind, runId: string, item?: string): string => `${batchItemDir(kind, runId, item)}/result.json`;
100
+
101
+ /* A FILE THAT IS HALF-WRITTEN, or written by a build whose shape has since changed, IS SKIPPED rather than
102
+ * thrown on. One bad directory must not blank a whole history, and a run directory is written by an agent
103
+ * mid-turn, so reading one that is not finished being written is ordinary rather than exceptional.
104
+ *
105
+ * The caller supplies the shape check, because the shape is the pack's. This owns only the two failure modes
106
+ * every reader shares: text that is not JSON, and JSON that is not an object. */
107
+ export const parseBatchFile = <T>(text: string, shape: (value: Record<string, unknown>) => T | undefined): T | undefined => {
108
+ try {
109
+ const parsed: unknown = JSON.parse(text);
110
+ return typeof parsed !== `object` || parsed === null ? undefined : shape(parsed as Record<string, unknown>);
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ };
115
+
116
+ /* WHAT THE AGENT IS TOLD ABOUT WHERE TO LEAVE ITS ANSWER, appended to whatever prompt the pack composed.
117
+ *
118
+ * WHY THE AGENT WRITES A FILE AND NOT A ROUTE. A ledger is a daemon route, and reaching it from a turn would
119
+ * mean handing the agent a token and a client it needs for nothing else. Writing one small JSON file is
120
+ * something every agent can already do, and the surface promotes finished runs when it next sees them. The
121
+ * promotion is idempotent and re-runs on every poll, so nothing is lost by not being watched.
122
+ *
123
+ * `outcomes` is the pack's vocabulary and is spelled out in full, because a closed set is what lets a surface
124
+ * debounce without hiding anything: an agent that verified some findings and concluded they were false
125
+ * positives has to be able to SAY so, or the next poll starts the same turn again forever. Pass the
126
+ * explanations with the words — a model that reads an outcome as an admission of having done nothing useful
127
+ * will avoid it and report something else, and the surface never goes quiet.
128
+ *
129
+ * The closing line is not decoration. A turn that concludes there was nothing to do and writes no file is
130
+ * indistinguishable from a turn that died, and the surface has to show the second as an unknown. */
131
+ export const batchReportingClause = (params: {
132
+ readonly path: string;
133
+ readonly fields: string;
134
+ readonly outcomes?: string | undefined;
135
+ }): string =>
136
+ [
137
+ `When you are finished, write your conclusion to ${params.path} as JSON:`,
138
+ params.fields,
139
+ ...(params.outcomes === undefined ? [] : [params.outcomes]),
140
+ `Write that file even if you conclude there was nothing to do.`,
141
+ ].join(`\n\n`);
142
+
143
+ /* THE BODY OF THE `POST /agent` THAT STARTS ONE ITEM, so the flag combination that makes a run a run is decided
144
+ * once. `isolated: true` with a conversationId is the shape (and the only shape) that registers a fleet entry,
145
+ * which is why none of these packs owns session machinery; `unattended: true` is what the turn IS — started by
146
+ * a row rather than by a person at a composer — and it is what makes the daemon answer with the owner's
147
+ * `agentRunModels` unless the caller pinned a model on the row's caret, in which case the pick rides along and
148
+ * the daemon's fill step leaves it alone.
149
+ *
150
+ * PERMISSIONS AND ISOLATION ARE THE CALLER'S, deliberately. They are the two decisions that differ by kind and
151
+ * both are about safety rather than plumbing: an acceptance test that parks on a permission card is a test that
152
+ * never finishes, so that surface trades the prompt away; a maintenance chore is different in kind — nobody is
153
+ * waiting on it, it may take until tomorrow, and a sweep that can answer its own permission prompts is exactly
154
+ * the thing an owner would want to have been asked about. A default here would decide that for both. */
155
+ export interface BatchTurnPick {
156
+ readonly provider: string;
157
+ readonly model?: string | undefined;
158
+ readonly effort?: string | undefined;
159
+ }
160
+
161
+ export const batchTurnBody = (params: {
162
+ readonly prompt: string;
163
+ readonly title: string;
164
+ readonly conversationId: string;
165
+ readonly pick?: BatchTurnPick | undefined;
166
+ readonly extra?: Readonly<Record<string, unknown>> | undefined;
167
+ }): Record<string, unknown> => ({
168
+ prompt: params.prompt,
169
+ title: params.title.slice(0, 80),
170
+ conversationId: params.conversationId,
171
+ isolated: true,
172
+ unattended: true,
173
+ ...(params.pick === undefined
174
+ ? {}
175
+ : {
176
+ agent: params.pick.provider,
177
+ ...(params.pick.model === undefined ? {} : { model: params.pick.model }),
178
+ ...(params.pick.effort === undefined ? {} : { effort: params.pick.effort }),
179
+ }),
180
+ ...params.extra,
181
+ });