@sigrun/sigrun 0.1.6 → 0.2.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 (126) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +352 -36
  3. package/dist/blocks/schema.js +9 -2
  4. package/dist/blocks/validate.js +57 -8
  5. package/dist/cli.js +26 -2
  6. package/dist/config.js +67 -1
  7. package/dist/extension.d.ts +189 -15
  8. package/dist/harness/ambient.js +39 -185
  9. package/dist/harness/blocks/entries.js +47 -0
  10. package/dist/harness/blocks/state.js +58 -0
  11. package/dist/harness/blocks/tools.js +274 -0
  12. package/dist/harness/boot.js +253 -20
  13. package/dist/harness/builtins.js +15 -11
  14. package/dist/harness/event-bus.js +105 -10
  15. package/dist/harness/identity/accounts.js +186 -0
  16. package/dist/harness/identity/groups.js +14 -0
  17. package/dist/harness/identity/index.js +13 -6
  18. package/dist/harness/identity/json-store.js +15 -4
  19. package/dist/harness/identity/setup-code-check.js +9 -0
  20. package/dist/harness/identity/tokens.js +28 -5
  21. package/dist/harness/identity/users.js +111 -12
  22. package/dist/harness/interactions.js +292 -136
  23. package/dist/harness/loader.js +271 -17
  24. package/dist/harness/packages.js +1 -1
  25. package/dist/harness/reserved-sections.js +21 -0
  26. package/dist/harness/seam-host.js +80 -6
  27. package/dist/harness/settings.js +63 -2
  28. package/dist/harness/stores.js +241 -4
  29. package/dist/harness/user-services.js +391 -0
  30. package/dist/harness/user-tools.js +187 -0
  31. package/dist/harness/workspace.js +61 -57
  32. package/dist/jobs/fire.js +10 -16
  33. package/dist/sandbox/availability.js +162 -0
  34. package/dist/sandbox/command-observer.js +59 -0
  35. package/dist/sandbox/grants.js +159 -0
  36. package/dist/sandbox/helper.js +29 -0
  37. package/dist/sandbox/isolation.js +29 -0
  38. package/dist/sandbox/launch.js +20 -0
  39. package/dist/sandbox/os-account.js +49 -0
  40. package/dist/sandbox/policy.js +253 -0
  41. package/dist/sandbox/pool.js +548 -0
  42. package/dist/sandbox/protocol.js +167 -0
  43. package/dist/sandbox/sandboxed-env.js +245 -0
  44. package/dist/sandbox/unavailable-env.js +49 -0
  45. package/dist/sandbox/worker.js +281 -0
  46. package/dist/serve.js +82 -7
  47. package/dist/server/auth/access-principal.js +16 -0
  48. package/dist/server/auth/pipeline.js +47 -33
  49. package/dist/server/blocks/answers.js +94 -0
  50. package/dist/server/blocks/chat-surfaces.js +393 -0
  51. package/dist/server/blocks/notes.js +216 -0
  52. package/dist/server/blocks/summaries.js +328 -0
  53. package/dist/server/commands.js +5 -0
  54. package/dist/server/content-parts-lazy.js +13 -0
  55. package/dist/server/conversation-handler.js +2 -5
  56. package/dist/server/conversation-ops.js +138 -37
  57. package/dist/server/helpers.js +18 -6
  58. package/dist/server/host-bridge.js +40 -20
  59. package/dist/server/images/cache.js +205 -0
  60. package/dist/server/images/convert.js +13 -0
  61. package/dist/server/images/fetch.js +302 -0
  62. package/dist/server/images/ranges.js +42 -0
  63. package/dist/server/images/rewrite.js +94 -0
  64. package/dist/server/images/sign.js +84 -0
  65. package/dist/server/images/sniff.js +62 -0
  66. package/dist/server/index.js +144 -4
  67. package/dist/server/login/flows.js +126 -0
  68. package/dist/server/login/http.js +31 -0
  69. package/dist/server/login/resolve.js +133 -0
  70. package/dist/server/login/setup-claim.js +55 -0
  71. package/dist/server/login/usernames.js +15 -0
  72. package/dist/server/ownership.js +13 -0
  73. package/dist/server/routes/admin.js +58 -0
  74. package/dist/server/routes/auth.js +88 -2
  75. package/dist/server/routes/conversations.js +3 -6
  76. package/dist/server/routes/events.js +28 -3
  77. package/dist/server/routes/extensions.js +37 -5
  78. package/dist/server/routes/images.js +267 -0
  79. package/dist/server/routes/interactions.js +57 -68
  80. package/dist/server/routes/jobs.js +2 -8
  81. package/dist/server/routes/login.js +223 -0
  82. package/dist/server/routes/me.js +90 -0
  83. package/dist/server/routes/meta.js +15 -0
  84. package/dist/server/routes/onboarding.js +94 -84
  85. package/dist/server/routes/settings-sections.js +190 -0
  86. package/dist/server/routes/settings.js +16 -4
  87. package/dist/server/routes/submissions.js +1 -1
  88. package/dist/server/routes/surfaces.js +175 -23
  89. package/dist/server/routes/users.js +116 -20
  90. package/dist/server/schema-fixture.js +213 -0
  91. package/dist/server/session-meta.js +3 -0
  92. package/dist/server/shutdown.js +11 -4
  93. package/dist/server/static.js +18 -0
  94. package/dist/server/surfaces/dedup.js +9 -2
  95. package/dist/server/surfaces/service.js +181 -13
  96. package/dist/server/surfaces/settings-surfaces.js +597 -0
  97. package/dist/server/turns/submit-turn.js +26 -0
  98. package/dist/server/views.js +16 -0
  99. package/dist/settings/core-sections.js +532 -0
  100. package/dist/settings/pending.js +181 -0
  101. package/dist/settings/registry.js +453 -0
  102. package/dist/skills/index.js +6 -0
  103. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
  104. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  105. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  106. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  107. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  108. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  109. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  110. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  111. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  112. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  113. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  114. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  115. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  116. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  117. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  118. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  119. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  120. package/node_modules/@sigrun/shared/package.json +1 -1
  121. package/package.json +11 -6
  122. package/webui/dist/assets/index-CxB969qp.js +55 -0
  123. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  124. package/webui/dist/index.html +2 -2
  125. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  126. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Change-record summaries (UI-blocks U5 Task 2, spec §8.3): core writes
3
+ * each `ChangeRecord.summary` from the TREE, using the labels the user
4
+ * saw — the item's `title` slot value, its 1-based position, the
5
+ * group's (or the enclosing section's) title, and the form field's
6
+ * `label`.
7
+ *
8
+ * The pinned strings (the spec's own examples):
9
+ * - `moved 'Clair de Lune' from 4th to 1st in 'Ranking'`
10
+ * - `checked 'milk' in 'Shopping'` (and `unchecked …`)
11
+ * - `set 'Notes' to '…'` (a `turn: false` submit writes the form's
12
+ * values at its `bind`; one `set` per field, joined by `"; "`;
13
+ * values over {@link SUMMARY_VALUE_MAX} characters truncate with `…`)
14
+ *
15
+ * Never throws: every lookup is optional-typed (a missing binding, a
16
+ * payload shape the walk does not know) and falls back to the plainest
17
+ * label available — a summary is a human-read string, not a write path.
18
+ */
19
+ import { resolvePointer } from "@sigrun/shared/blocks";
20
+ /** The value-render cap (the brief): values over 80 characters truncate with `…`. */
21
+ export const SUMMARY_VALUE_MAX = 80;
22
+ /** Render one value: plain JSON form, capped at 80 characters (`…`). */
23
+ function renderValue(value) {
24
+ const text = typeof value === "string" ? value : JSON.stringify(value);
25
+ if (text.length <= SUMMARY_VALUE_MAX)
26
+ return text;
27
+ return `${text.slice(0, SUMMARY_VALUE_MAX)}…`;
28
+ }
29
+ /** The 1-based ordinal the position line reads ("1st", "2nd", "11th"). */
30
+ export function ordinal(index) {
31
+ const oneBased = index + 1;
32
+ const tail100 = oneBased % 100;
33
+ if (tail100 >= 11 && tail100 <= 13)
34
+ return `${oneBased}th`;
35
+ switch (oneBased % 10) {
36
+ case 1:
37
+ return `${oneBased}st`;
38
+ case 2:
39
+ return `${oneBased}nd`;
40
+ case 3:
41
+ return `${oneBased}rd`;
42
+ default:
43
+ return `${oneBased}th`;
44
+ }
45
+ }
46
+ /** A `move`/`edit` payload endpoint (the `{group?, index}` shape §6.3 sends). */
47
+ function readSpot(value) {
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
49
+ return undefined;
50
+ }
51
+ const record = value;
52
+ return {
53
+ ...(typeof record.group === "string" ? { group: record.group } : {}),
54
+ ...(typeof record.index === "number" ? { index: record.index } : {}),
55
+ };
56
+ }
57
+ /** Resolve a bindable against `data` (`item` scopes `./` bindings). */
58
+ function bound(value, data, item) {
59
+ if (value === undefined)
60
+ return undefined;
61
+ if (typeof value === "string")
62
+ return value;
63
+ const resolved = resolvePointer(data, value.$, item);
64
+ return typeof resolved === "string" ? resolved : undefined;
65
+ }
66
+ /** A plain-record check (the tree walk's node shape). */
67
+ function isRec(value) {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
69
+ }
70
+ /** Yield every node of a tree, structurally (the service.ts walk). */
71
+ function* walkNodes(root) {
72
+ yield root;
73
+ const childLists = [
74
+ root.root,
75
+ root.children,
76
+ root.fields,
77
+ root.main,
78
+ root.sidebar,
79
+ root.detail,
80
+ ];
81
+ for (const list of childLists) {
82
+ if (Array.isArray(list)) {
83
+ for (const child of list) {
84
+ if (isRec(child)) {
85
+ if (isRec(child.block)) {
86
+ yield* walkNodes(child.block);
87
+ }
88
+ else {
89
+ yield* walkNodes(child);
90
+ }
91
+ }
92
+ }
93
+ }
94
+ else if (isRec(list)) {
95
+ yield* walkNodes(list);
96
+ }
97
+ }
98
+ if (isRec(root.item))
99
+ yield* walkNodes(root.item);
100
+ }
101
+ /** The label the user saw above one block: the nearest enclosing section title. */
102
+ function sectionTitleAround(tree, inner, data) {
103
+ const wanted = inner;
104
+ const visit = (node, title) => {
105
+ const nextTitle = node.type === "section" && node.title !== undefined
106
+ ? bound(node.title, data, undefined)
107
+ : title;
108
+ if (node === wanted)
109
+ return nextTitle;
110
+ const childLists = [node.root, node.children, node.fields, node.main, node.sidebar, node.detail];
111
+ for (const list of childLists) {
112
+ if (Array.isArray(list)) {
113
+ for (const child of list) {
114
+ if (!isRec(child))
115
+ continue;
116
+ const hit = isRec(child.block)
117
+ ? visit(child.block, nextTitle)
118
+ : visit(child, nextTitle);
119
+ if (hit !== undefined)
120
+ return hit;
121
+ }
122
+ }
123
+ else if (isRec(list)) {
124
+ const hit = visit(list, nextTitle);
125
+ if (hit !== undefined)
126
+ return hit;
127
+ }
128
+ }
129
+ if (isRec(node.item)) {
130
+ const hit = visit(node.item, nextTitle);
131
+ if (hit !== undefined)
132
+ return hit;
133
+ }
134
+ return undefined;
135
+ };
136
+ // SAFETY: the walk reads only structural record fields (type/children/
137
+ // item/…) — the wire `Tree` and the loose `JsonObject` view agree on
138
+ // those, and every read is typeof-guarded below.
139
+ const found = visit(tree.root, undefined);
140
+ return found;
141
+ }
142
+ /** The group's display title: the declared group's `title` (a binding resolves
143
+ * against the surface data the actor saw), else its id. */
144
+ function groupTitleOf(collection, groupId, data) {
145
+ const declared = (collection.groups ?? []).find((candidate) => candidate.id === groupId);
146
+ if (declared === undefined)
147
+ return groupId;
148
+ return (bound(declared.title, data, undefined) ?? declared.id);
149
+ }
150
+ /** One summary line, quoted and labelled; empty when nothing was found. */
151
+ function moveSummary(tree, data, payload) {
152
+ const itemId = payload.itemId;
153
+ if (typeof itemId !== "string")
154
+ return undefined;
155
+ const from = readSpot(payload.from);
156
+ const to = readSpot(payload.to);
157
+ if (from === undefined || to === undefined)
158
+ return undefined;
159
+ // The collection owning this move: the one whose items array holds
160
+ // the item (multiple collections may bind the same name handler;
161
+ // the first holding the item wins).
162
+ // SAFETY: the walk reads only structural record fields (children/items/
163
+ // type names) the wire Tree and the loose JsonObject view agree on;
164
+ // every field read below is typeof-guarded.
165
+ for (const node of walkNodes(tree.root)) {
166
+ if (node.type !== "collection")
167
+ continue;
168
+ const itemsPath = node.items;
169
+ if (typeof itemsPath !== "object" || itemsPath === null)
170
+ continue;
171
+ const pointer = itemsPath.$;
172
+ if (typeof pointer !== "string")
173
+ continue;
174
+ const items = resolvePointer(data, pointer);
175
+ if (!Array.isArray(items))
176
+ continue;
177
+ const at = items.findIndex((candidate) => isRec(candidate) && candidate.id === itemId);
178
+ if (at < 0)
179
+ continue;
180
+ const item = items[at];
181
+ const card = isRec(node.item) ? node.item : undefined;
182
+ const title = card === undefined
183
+ ? undefined
184
+ : bound(card.title, data, item);
185
+ if (title === undefined)
186
+ continue;
187
+ // SAFETY: the walk narrowed `node` to a collection record with an
188
+ // items binding and an item template; the remaining props are read
189
+ // only through the declared-block type's own names.
190
+ const collection = node;
191
+ // A grouped collection labels by the GROUP's title (the label the
192
+ // user saw over the row); an ungrouped one by the section's.
193
+ const scope = collection.groupBy === undefined
194
+ ? (sectionTitleAround(tree, node, data) ?? "")
195
+ : groupTitleOf(collection, to.group ?? from.group ?? "", data);
196
+ const where = scope === "" ? "" : ` in '${scope}'`;
197
+ // The `from` position names the item's located (current) index —
198
+ // the payload's from.index is only a hint: an interleaved edit
199
+ // may have moved the row before this action's commit ran, and
200
+ // the summary must name the positions at APPLY time. A grouped
201
+ // collection renders WITHIN-GROUP positions (the client's
202
+ // to.index is the drop index inside the lane), so the "from"
203
+ // ordinal counts only the item's own lane members — the raw
204
+ // array index would name a position the user never saw.
205
+ let fromIndex = at;
206
+ const groupBy = collection.groupBy;
207
+ if (groupBy !== undefined && isRec(item)) {
208
+ const groupValue = item[groupBy];
209
+ let before = 0;
210
+ for (let earlier = 0; earlier < at; earlier++) {
211
+ const candidate = items[earlier];
212
+ if (isRec(candidate) && candidate[groupBy] === groupValue) {
213
+ before += 1;
214
+ }
215
+ }
216
+ fromIndex = before;
217
+ }
218
+ return `moved '${title}' from ${ordinal(fromIndex)} to ${ordinal(to.index ?? fromIndex)}${where}`;
219
+ }
220
+ return undefined;
221
+ }
222
+ /** The check summary: `checked 'milk' in 'Shopping'`. */
223
+ function checkSummary(tree, data, payload) {
224
+ const itemId = payload.itemId;
225
+ if (typeof itemId !== "string")
226
+ return undefined;
227
+ // SAFETY: the walk reads only structural record fields the wire Tree
228
+ // and the loose JsonObject view agree on; every read is guarded.
229
+ for (const node of walkNodes(tree.root)) {
230
+ if (node.type !== "collection" || typeof node.checkable !== "string")
231
+ continue;
232
+ const itemsPath = node.items;
233
+ if (typeof itemsPath !== "object" || itemsPath === null)
234
+ continue;
235
+ const pointer = itemsPath.$;
236
+ if (typeof pointer !== "string")
237
+ continue;
238
+ const items = resolvePointer(data, pointer);
239
+ if (!Array.isArray(items))
240
+ continue;
241
+ const at = items.findIndex((candidate) => isRec(candidate) && candidate.id === itemId);
242
+ if (at < 0)
243
+ continue;
244
+ const item = items[at];
245
+ const card = isRec(node.item) ? node.item : undefined;
246
+ const title = card === undefined
247
+ ? undefined
248
+ : bound(card.title, data, item);
249
+ if (title === undefined)
250
+ continue;
251
+ const scope = sectionTitleAround(tree, node, data) ?? "";
252
+ const where = scope === "" ? "" : ` in '${scope}'`;
253
+ // `value` omitted means the plain toggle-on the client sends for a
254
+ // check (planAction defaults true): the verb must agree with the
255
+ // written value.
256
+ const verb = payload.value === false ? "unchecked" : "checked";
257
+ return `${verb} '${title}'${where}`;
258
+ }
259
+ return undefined;
260
+ }
261
+ /** A form field's declared label (a binding resolves against `data`). */
262
+ function fieldLabel(field, data) {
263
+ const label = bound(field.label, data, undefined);
264
+ return label ?? field.name;
265
+ }
266
+ /** The submit summary: one `set 'Label' to 'value'` per field, `"; "`-joined. */
267
+ function submitSummary(tree, data, payload) {
268
+ const values = payload.values;
269
+ if (!isRec(values))
270
+ return undefined;
271
+ const parts = [];
272
+ // SAFETY: the walk reads only structural record fields the wire Tree
273
+ // and the loose JsonObject view agree on; every read is guarded.
274
+ for (const node of walkNodes(tree.root)) {
275
+ if (node.type !== "form" || !Array.isArray(node.children))
276
+ continue;
277
+ const fields = [];
278
+ for (const child of node.children) {
279
+ if (!isRec(child))
280
+ continue;
281
+ if (typeof child.name === "string" && "label" in child) {
282
+ // SAFETY: the record passed the field shape check (a `name` +
283
+ // a `label` member); Field's own members are read only through
284
+ // `fieldLabel`'s guarded accessors.
285
+ fields.push(child);
286
+ }
287
+ else if (child.type === "section" && Array.isArray(child.children)) {
288
+ for (const grandchild of child.children) {
289
+ if (isRec(grandchild) && typeof grandchild.name === "string") {
290
+ // SAFETY: same field-shape check as above.
291
+ fields.push(grandchild);
292
+ }
293
+ }
294
+ }
295
+ }
296
+ // Only the form(s) whose declared fields appear in the payload.
297
+ const named = fields.filter((field) => field.name in values);
298
+ if (named.length === 0)
299
+ continue;
300
+ for (const field of named) {
301
+ const value = values[field.name];
302
+ parts.push(`set '${fieldLabel(field, data)}' to '${renderValue(value ?? null)}'`);
303
+ }
304
+ }
305
+ if (parts.length === 0)
306
+ return undefined;
307
+ return parts.join("; ");
308
+ }
309
+ /**
310
+ * The summary for one action (spec §8.3). `data` is the surface's data
311
+ * BEFORE the edit applies — the positions and titles the user saw.
312
+ */
313
+ export function summarizeAction(tree, data, action, payload) {
314
+ const record = isRec(payload) ? payload : {};
315
+ const fallback = "name" in action && typeof action.name === "string"
316
+ ? `${action.type} ${action.name}`
317
+ : action.type;
318
+ if (action.type === "move") {
319
+ return moveSummary(tree, data, record) ?? fallback;
320
+ }
321
+ if (action.type === "edit") {
322
+ return checkSummary(tree, data, record) ?? fallback;
323
+ }
324
+ if (action.type === "submit") {
325
+ return submitSummary(tree, data, record) ?? fallback;
326
+ }
327
+ return fallback;
328
+ }
@@ -101,6 +101,11 @@ export const SLASH_COMMANDS = new Map([
101
101
  description: "List jobs (name, next run, last outcome)",
102
102
  endpoint: { method: "GET", path: "/v1/jobs" },
103
103
  },
104
+ {
105
+ name: "settings",
106
+ description: "Open the settings panel (sections + search)",
107
+ endpoint: { method: "GET", path: "/v1/settings/sections" },
108
+ },
104
109
  ].map((command) => [command.name, command]));
105
110
  /**
106
111
  * Merge the core slash-command table with every extension's `commands`
@@ -0,0 +1,13 @@
1
+ /** Resolve parts against a lazily-available env: text parts never touch it. */
2
+ import { resolveParts, } from "./content-parts.js";
3
+ /**
4
+ * The lazy-env resolve: text parts (the whole answer-projection case)
5
+ * resolve without the env; the first file/image part awaits it. A sandbox
6
+ * host that cannot build the env therefore still admits answers.
7
+ */
8
+ export async function resolvePartsLazy(parts, envPromise, context) {
9
+ if (parts.every((part) => part.type === "text")) {
10
+ return resolveParts(parts, {}, context);
11
+ }
12
+ return resolveParts(parts, await envPromise, context);
13
+ }
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
24
24
  import { accessOf } from "@sigrun/shared/access";
25
- import { isSuperuser } from "./auth/principal.js";
25
+ import { accessPrincipalOf } from "./auth/access-principal.js";
26
26
  import { conversationId } from "./helpers.js";
27
27
  import { SessionMetaDoc } from "./session-meta.js";
28
28
  /**
@@ -43,10 +43,7 @@ export function loadConversation(ctx, need = "read") {
43
43
  .send({ error: "conversation_not_found" });
44
44
  }
45
45
  const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
46
- const access = accessOf({
47
- user: request.principal.user,
48
- superuser: isSuperuser(request.principal),
49
- }, meta ?? {});
46
+ const access = accessOf(accessPrincipalOf(request.principal, ctx.opened.identity.users), meta ?? {});
50
47
  if (access === "none") {
51
48
  // Hidden is 404 on every :id route (spec §5.9).
52
49
  return await reply
@@ -16,14 +16,17 @@
16
16
  * caller) to map — the same split the inline route bodies had.
17
17
  */
18
18
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
+ import { AgentDoc, configure } from "@earendil-works/pi-durable";
19
20
  import { accessOf, isSharing, } from "@sigrun/shared/access";
20
21
  import { extname } from "node:path";
21
22
  import { touchActivity } from "../harness/activity.js";
22
23
  import { resolveServedModelRefreshing } from "../harness/providers/refresh.js";
23
24
  import { isSuperuser } from "./auth/principal.js";
24
- import { isConfinedPath, parseSubmissionInput, resolveParts } from "./content-parts.js";
25
+ import { isConfinedPath, parseSubmissionInput } from "./content-parts.js";
26
+ import { resolvePartsLazy } from "./content-parts-lazy.js";
25
27
  import { CommandUsageError, expandInput, EXPANDED_ENTRY_KIND, } from "../commands/expand.js";
26
28
  import { conversationEnv, readView, SCAN_PAGE_SIZE, storeAgentChange, THINKING_LEVELS, WHEN_BUSY_MODES, } from "./helpers.js";
29
+ import { writePendingNote } from "./blocks/notes.js";
27
30
  import { applySessionMetaPatch, DEFAULT_SESSION_META, SessionMetaDoc, validateSessionMetaPatch, } from "./session-meta.js";
28
31
  /**
29
32
  * One client error from a conversation op: the exact body the routes
@@ -165,6 +168,15 @@ export async function createConversationOp(ctx, input = {}) {
165
168
  });
166
169
  }
167
170
  }
171
+ for (const name of input.agent.extensions ?? []) {
172
+ const at = name.indexOf("@");
173
+ if (at > 0 && name.slice(at + 1) !== input.owner) {
174
+ throw new ConversationOpError({
175
+ error: "invalid_field",
176
+ field: "agent.extensions",
177
+ });
178
+ }
179
+ }
168
180
  }
169
181
  const selection = input.agent === undefined
170
182
  ? undefined
@@ -198,13 +210,16 @@ export async function createConversationOp(ctx, input = {}) {
198
210
  if (input.owner !== undefined)
199
211
  draft.owner = input.owner;
200
212
  // Copy-on-assignment (the tracker requires alias-free roots):
201
- // the people-scope array is cloned; the closed scopes spread.
213
+ // the people-scope arrays are cloned; the closed scopes spread.
202
214
  if (input.sharing !== undefined) {
203
215
  draft.sharing =
204
216
  input.sharing.scope === "people"
205
217
  ? {
206
218
  scope: "people",
207
219
  users: [...input.sharing.users],
220
+ ...(input.sharing.groups === undefined
221
+ ? {}
222
+ : { groups: [...input.sharing.groups] }),
208
223
  level: input.sharing.level,
209
224
  }
210
225
  : { ...input.sharing };
@@ -213,6 +228,7 @@ export async function createConversationOp(ctx, input = {}) {
213
228
  draft.name = input.name;
214
229
  if (input.source !== undefined)
215
230
  draft.source = input.source;
231
+ await opened.rewriteUserSelection(tx, conversation.id, undefined, input.owner);
216
232
  }, BACKGROUND_CONTEXT);
217
233
  const view = await readView(conversation);
218
234
  return { id: conversation.id, view };
@@ -264,6 +280,11 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
264
280
  (typeof whenBusy !== "string" || !WHEN_BUSY_MODES.includes(whenBusy))) {
265
281
  throw new ConversationOpError({ error: "invalid_field", field: "whenBusy" });
266
282
  }
283
+ // The §8.3 change note precedes EVERY turn admission (the note write
284
+ // precedes `expandInput`: an expansion failure — `invalid_input` —
285
+ // never strands unnoted edits without at least the note decision
286
+ // point). A block answer's surface is excluded (§8.4 folded it).
287
+ await writePendingNote(opened.harness, conversation, opts.skipNoteFor, BACKGROUND_CONTEXT);
267
288
  // Resolve path-parts to inline content NOW, against the live
268
289
  // workspace: missing paths become explicit error parts; other
269
290
  // workspace failures throw — fail-loud. Command expansion (Task 2c)
@@ -277,36 +298,67 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
277
298
  // ordinary user input: it maps to the same `invalid_input` 400 shape
278
299
  // `parseSubmissionInput` uses, never a 500. Anything else expansion
279
300
  // throws is a genuine bug and still falls through as one.
280
- const env = await conversationEnv(opened, conversation);
301
+ // The workspace env resolves lazily (a promise, awaited only by
302
+ // resolvePartsLazy's file/image parts): an answer submission (opts.entry)
303
+ // never needs the filesystem, and on a host where the sandbox helper is
304
+ // unavailable the env would throw — the answer must not depend on it.
305
+ // A THUNK, not an eager promise: conversationEnv throws (not rejects)
306
+ // on hosts where the sandbox helper is unavailable, and an eager
307
+ // creation would fail the ANSWER path too (opts.entry never needs the
308
+ // filesystem). The thunk is invoked only by the expansion branch.
309
+ const envThunk = () => conversationEnv(opened, conversation);
281
310
  let expansion;
282
- try {
283
- expansion = await expandInput(parsed.parts, opened.commandExpansion, env, BACKGROUND_CONTEXT);
311
+ if (opts.entry !== undefined) {
312
+ // A server-built entry (a block answer) skips expansion entirely:
313
+ // its content is a projection that never starts with `/`.
314
+ expansion = { parts: parsed.parts };
315
+ }
316
+ else {
317
+ try {
318
+ const env = await envThunk();
319
+ expansion = await expandInput(parsed.parts, opened.commandExpansion, env, BACKGROUND_CONTEXT);
320
+ }
321
+ catch (error) {
322
+ if (error instanceof CommandUsageError) {
323
+ throw new ConversationOpError({ error: "invalid_input" });
324
+ }
325
+ throw error;
326
+ }
284
327
  }
285
- catch (error) {
286
- if (error instanceof CommandUsageError) {
287
- throw new ConversationOpError({ error: "invalid_input" });
328
+ // The entry disjunction (the brief's enforced invariant): `opts.entry`
329
+ // and expansion are mutually exclusive BY CONSTRUCTION — the answer's
330
+ // content is a server-built projection that never starts with `/`. A
331
+ // state where both would be set (an entry-carrying call whose input
332
+ // expansion claims) is a programming error, never a silent overwrite,
333
+ // and throws BEFORE any submission is admitted.
334
+ if (opts.entry !== undefined) {
335
+ const first = parsed.parts[0];
336
+ if (first !== undefined &&
337
+ first.type === "text" &&
338
+ first.text.startsWith("/")) {
339
+ throw new Error("submitOp: opts.entry with an expandable input is a programming error (entry and the expanded entry are mutually exclusive)");
288
340
  }
289
- throw error;
290
341
  }
291
- const content = await resolveParts(expansion.parts, env, BACKGROUND_CONTEXT);
342
+ const content = await resolvePartsLazy(expansion.parts, envThunk(), BACKGROUND_CONTEXT);
343
+ const entry = opts.entry !== undefined
344
+ ? opts.entry
345
+ : expansion.typed === undefined
346
+ ? undefined
347
+ : {
348
+ kind: EXPANDED_ENTRY_KIND,
349
+ data: {
350
+ typed: expansion.typed,
351
+ ...(expansion.command === undefined
352
+ ? {}
353
+ : { command: expansion.command }),
354
+ },
355
+ };
292
356
  const draft = {
293
357
  type: "input",
294
358
  content,
295
359
  ...(opts.requestId === undefined ? {} : { requestId: opts.requestId }),
296
360
  ...(whenBusy === undefined ? {} : { whenBusy: whenBusy }),
297
- ...(expansion.typed === undefined
298
- ? {}
299
- : {
300
- entry: {
301
- kind: EXPANDED_ENTRY_KIND,
302
- data: {
303
- typed: expansion.typed,
304
- ...(expansion.command === undefined
305
- ? {}
306
- : { command: expansion.command }),
307
- },
308
- },
309
- }),
361
+ ...(entry === undefined ? {} : { entry }),
310
362
  };
311
363
  // The submit pass-through is retired (§6.7): the memory seam's
312
364
  // runSettled owns capture, so submissions admit directly.
@@ -419,7 +471,7 @@ export async function configureOp(ctx, conversation, body) {
419
471
  throw new ConversationOpError({ error: "invalid_body" });
420
472
  }
421
473
  const record = body;
422
- const unknownKey = Object.keys(record).find((key) => key !== "model" && key !== "thinking");
474
+ const unknownKey = Object.keys(record).find((key) => key !== "model" && key !== "thinking" && key !== "extensions");
423
475
  if (unknownKey !== undefined) {
424
476
  throw new ConversationOpError({
425
477
  error: "invalid_field",
@@ -428,7 +480,21 @@ export async function configureOp(ctx, conversation, body) {
428
480
  }
429
481
  const model = record.model;
430
482
  const thinking = record.thinking;
431
- if (model === undefined && thinking === undefined) {
483
+ const extensions = record.extensions;
484
+ if (extensions !== undefined) {
485
+ if (!Array.isArray(extensions) ||
486
+ extensions.some((name) => typeof name !== "string")) {
487
+ throw new ConversationOpError({ error: "invalid_field", field: "extensions" });
488
+ }
489
+ const owner = (await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT))?.owner;
490
+ for (const name of extensions) {
491
+ const at = name.indexOf("@");
492
+ if (at > 0 && name.slice(at + 1) !== owner) {
493
+ throw new ConversationOpError({ error: "invalid_field", field: "extensions" });
494
+ }
495
+ }
496
+ }
497
+ if (model === undefined && thinking === undefined && extensions === undefined) {
432
498
  // A configure that changes nothing is a client error, not a no-op.
433
499
  throw new ConversationOpError({ error: "invalid_body" });
434
500
  }
@@ -455,6 +521,13 @@ export async function configureOp(ctx, conversation, body) {
455
521
  const storeThinking = storeThinkingFor(opened, parsedModel);
456
522
  await conversation.configure({
457
523
  ...(parsedModel === undefined ? {} : { model: parsedModel }),
524
+ ...(extensions === undefined
525
+ ? {}
526
+ : {
527
+ extensions: extensions.map((name) => ({
528
+ name,
529
+ })),
530
+ }),
458
531
  ...(thinking !== undefined
459
532
  ? { thinkingLevel: thinking }
460
533
  : storeThinking === undefined
@@ -481,10 +554,9 @@ function storeThinkingFor(opened, model) {
481
554
  * run inputs and queued inbox items (all conversations); filtered to
482
555
  * this conversation, the LOWEST id is the active one — anything queued
483
556
  * behind a busy conversation was admitted later and has a higher id.
484
- * Nothing active is the 404 `no_active_submission`; a `not_found` abort
485
- * result (the record vanished between inspect and abort) is the same
486
- * honest answer. `already_placed`/`settled` still report which state
487
- * the submission was found in (abort is idempotent).
557
+ * Nothing active is the 404 `no_active_submission`. The 200 result
558
+ * reports the pre-abort state: queued input withdraws as `aborted`,
559
+ * while a placed run reports `already_placed` and is genuinely stopped.
488
560
  */
489
561
  export async function abortOp(ctx, conversation) {
490
562
  const { opened } = ctx;
@@ -499,13 +571,15 @@ export async function abortOp(ctx, conversation) {
499
571
  if (active === undefined) {
500
572
  throw new ConversationOpError({ error: "no_active_submission" }, 404);
501
573
  }
502
- const result = await opened.harness.abortSubmission(active.id, BACKGROUND_CONTEXT, conversation.id);
503
- // `not_found` means the submission's record vanished between `inspect`
504
- // and `abort` — the conversation is no longer busy, so the honest answer
505
- // is the same as nothing-active.
506
- if (result === "not_found") {
507
- throw new ConversationOpError({ error: "no_active_submission" }, 404);
508
- }
574
+ // inspect() returns only queued/placed records, so those are the only
575
+ // reachable result values. AbortResponse still permits "settled" for
576
+ // compatibility, but this op cannot honestly derive it from inspect.
577
+ const result = active.status === "queued" ? "aborted" : "already_placed";
578
+ // The old abortSubmission-only path WITHDREW queued inputs but could
579
+ // not stop a placed generation. Conversation.abort subsumes both: it
580
+ // withdraws queued inputs, marks and signals every live non-background
581
+ // task, then resolves once the conversation scope is idle (C4 /stop).
582
+ await conversation.abort(BACKGROUND_CONTEXT);
509
583
  return { result, submissionId: active.id };
510
584
  }
511
585
  /**
@@ -577,9 +651,30 @@ export async function forkOp(ctx, conversation, opts = {}) {
577
651
  // The fork inherits the parent's agent model — read the resolved
578
652
  // agent, fall back to the boot default like create.
579
653
  const agent = await conversation.agent(BACKGROUND_CONTEXT);
654
+ const inheritedSelection = (await opened.harness.snapshot(AgentDoc, conversation.id, BACKGROUND_CONTEXT))?.extensions;
655
+ // Preserve the stored selection VERBATIM here. `AgentChange` wants
656
+ // Extension-shaped names and maps them back to strings; looking names
657
+ // up in the registry would silently drop unresolved/late-install names.
658
+ const inheritedExtensions = Array.isArray(inheritedSelection)
659
+ ? inheritedSelection.map((name) => ({ name }))
660
+ : inheritedSelection === undefined
661
+ ? undefined
662
+ : {
663
+ ...(inheritedSelection.add === undefined
664
+ ? {}
665
+ : { add: inheritedSelection.add.map((name) => ({ name })) }),
666
+ ...(inheritedSelection.remove === undefined
667
+ ? {}
668
+ : { remove: inheritedSelection.remove.map((name) => ({ name })) }),
669
+ };
580
670
  const fork = await conversation.fork(at, {
581
671
  ownership: { kind: "ownerless" },
582
- agent: { model: agent.model ?? opened.defaultModel },
672
+ agent: {
673
+ model: agent.model ?? opened.defaultModel,
674
+ ...(inheritedExtensions === undefined
675
+ ? {}
676
+ : { extensions: inheritedExtensions }),
677
+ },
583
678
  }, BACKGROUND_CONTEXT);
584
679
  // The fork's sidebar identity: the copied meta doc carries the
585
680
  // source's name/autoName and the namer's attempt count; ONE commit
@@ -596,6 +691,11 @@ export async function forkOp(ctx, conversation, opts = {}) {
596
691
  const sourceMeta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
597
692
  const sourceName = sourceMeta?.name ?? sourceMeta?.autoName;
598
693
  await fork.commit(async (tx) => {
694
+ // A relative source cwd belongs to the source owner's workspace.
695
+ // Do not let a fork by someone else silently retarget it into theirs.
696
+ if (sourceMeta?.owner !== opts.owner) {
697
+ await configure(tx, fork.id, { cwd: null });
698
+ }
599
699
  const draft = await tx.doc(SessionMetaDoc, fork.id);
600
700
  delete draft.name;
601
701
  delete draft.autoName;
@@ -608,6 +708,7 @@ export async function forkOp(ctx, conversation, opts = {}) {
608
708
  if (sourceName !== undefined)
609
709
  draft.autoName = `${sourceName} (fork)`;
610
710
  draft.lastActivityAt = opened.now().toISOString();
711
+ await opened.rewriteUserSelection(tx, fork.id, sourceMeta?.owner, opts.owner);
611
712
  }, BACKGROUND_CONTEXT);
612
713
  const view = await readView(fork);
613
714
  return { id: fork.id, view };