ai-or-die 0.1.91 → 0.1.93

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -12,6 +12,10 @@ const DEFAULT_SSE_HEARTBEAT_MS = 15000;
12
12
  // Bound the artifact-push await so a stalled PTY write can't hold the /prompts
13
13
  // HTTP response open. On timeout we treat the push as failed and re-queue.
14
14
  const PUSH_TIMEOUT_MS = 4000;
15
+ // Bounded per-session event replay buffer (contract §2 / C-P0-4). Backs SSE
16
+ // Last-Event-ID reconnect replay, GET /history rehydrate, and the typed
17
+ // GET /await drain. Old events roll off once past the cap.
18
+ const MAX_REPLAY_EVENTS = 200;
15
19
 
16
20
  function realpathOrResolve(file) {
17
21
  let resolved = path.resolve(file);
@@ -143,6 +147,43 @@ function artifactPushEnabledFromEnv(raw) {
143
147
  return !/^(0|false|off|no)$/i.test(String(raw == null ? '' : raw).trim());
144
148
  }
145
149
 
150
+ // Normalize an inbound structured action (contract §2/§4) into a stored `action`
151
+ // ArtifactEvent payload (id/at added by _appendEvent). Requires action+elementId.
152
+ // Carries value/group/selected/selector/sourceLine when present. Returns null for
153
+ // anything malformed so a bad item can't poison the event log.
154
+ function normalizeActionPayload(a) {
155
+ if (!a || typeof a !== 'object') return null;
156
+ const action = typeof a.action === 'string' ? a.action.trim() : '';
157
+ const elementId = typeof a.elementId === 'string' ? a.elementId.trim() : '';
158
+ if (!action || !elementId) return null;
159
+ const payload = { kind: 'action', action, elementId };
160
+ if (typeof a.value === 'string') payload.value = a.value;
161
+ if (typeof a.group === 'string' && a.group) payload.group = a.group;
162
+ if (Array.isArray(a.selected)) {
163
+ payload.selected = a.selected
164
+ .map((s) => {
165
+ if (!s || typeof s !== 'object') return null;
166
+ const id = typeof s.elementId === 'string' ? s.elementId : '';
167
+ if (!id) return null;
168
+ const item = { elementId: id };
169
+ if (typeof s.value === 'string') item.value = s.value;
170
+ return item;
171
+ })
172
+ .filter(Boolean);
173
+ }
174
+ if (typeof a.selector === 'string') payload.selector = a.selector;
175
+ if (typeof a.sourceLine === 'number') payload.sourceLine = a.sourceLine;
176
+ return payload;
177
+ }
178
+
179
+ // Whether an action verb corresponds to a menu-style approval that should route
180
+ // to the structured control-plane respond path (contract §5) rather than the
181
+ // drain — approve / reject / choose. Multi-select (check/submit) and custom verbs
182
+ // always drain.
183
+ function isApprovalActionVerb(verb) {
184
+ return verb === 'approve' || verb === 'reject' || verb === 'choose';
185
+ }
186
+
146
187
  class ArtifactReviewStore extends EventEmitter {
147
188
  constructor() {
148
189
  super();
@@ -168,6 +209,15 @@ class ArtifactReviewStore extends EventEmitter {
168
209
  layoutWarnings: [],
169
210
  domSnapshot: null,
170
211
  chat: [],
212
+ // Typed, monotonically-id'd event log (contract §2). Holds agent-reply /
213
+ // comment / ended events for SSE reconnect replay, /history rehydrate, and
214
+ // the /await drain. Bounded to MAX_REPLAY_EVENTS.
215
+ events: [],
216
+ _seq: 0,
217
+ // Panel visibility (contract §9). 'dismissed' hides the panel while the
218
+ // review stays alive; server-authoritative so a reconnecting panel + the
219
+ // artifact_dismiss tool agree.
220
+ visibility: 'shown',
171
221
  presence: { connected: false, lastSeen: null },
172
222
  updatedAt: nowIso(),
173
223
  };
@@ -179,6 +229,9 @@ class ArtifactReviewStore extends EventEmitter {
179
229
  if (!Array.isArray(review.queuedPrompts)) review.queuedPrompts = [];
180
230
  if (!Array.isArray(review.layoutWarnings)) review.layoutWarnings = [];
181
231
  if (!Array.isArray(review.chat)) review.chat = [];
232
+ if (!Array.isArray(review.events)) review.events = [];
233
+ if (typeof review._seq !== 'number') review._seq = 0;
234
+ if (review.visibility !== 'dismissed') review.visibility = 'shown';
182
235
  if (!review.presence || typeof review.presence !== 'object') {
183
236
  review.presence = { connected: false, lastSeen: null };
184
237
  }
@@ -188,6 +241,83 @@ class ArtifactReviewStore extends EventEmitter {
188
241
  return review;
189
242
  }
190
243
 
244
+ // Append a typed event to the review's replay buffer, assigning the next
245
+ // per-session monotonic id (contract §2). Bounded: oldest events roll off past
246
+ // MAX_REPLAY_EVENTS. Returns the stored event (with id + at).
247
+ _appendEvent(review, evt) {
248
+ review._seq = (typeof review._seq === 'number' ? review._seq : 0) + 1;
249
+ const stored = Object.assign({ id: String(review._seq), at: nowIso() }, evt);
250
+ review.events.push(stored);
251
+ if (review.events.length > MAX_REPLAY_EVENTS) {
252
+ review.events.splice(0, review.events.length - MAX_REPLAY_EVENTS);
253
+ }
254
+ return stored;
255
+ }
256
+
257
+ // Derive the human-readable chat log from the typed event stream: agent-reply
258
+ // -> {role:'agent'}, comment -> {role:'you'}. Single source of truth so a
259
+ // reconnecting panel repaints exactly what happened.
260
+ _deriveChat(review) {
261
+ const out = [];
262
+ for (const e of review.events) {
263
+ if (e.kind === 'agent-reply') {
264
+ out.push({ id: e.id, role: 'agent', text: e.text == null ? '' : String(e.text), at: e.at });
265
+ } else if (e.kind === 'comment') {
266
+ out.push({ id: e.id, role: 'you', text: String(e.prompt || e.text || ''), at: e.at });
267
+ }
268
+ }
269
+ return out;
270
+ }
271
+
272
+ // Map a queued prompt (panel object or bare string) into a comment ArtifactEvent
273
+ // payload (id/at added by _appendEvent).
274
+ _commentPayloadFromPrompt(p) {
275
+ if (typeof p === 'string') return { kind: 'comment', prompt: p, text: '', selector: '' };
276
+ if (!p || typeof p !== 'object') return null;
277
+ const payload = {
278
+ kind: 'comment',
279
+ prompt: typeof p.prompt === 'string' ? p.prompt : '',
280
+ text: typeof p.text === 'string' ? p.text : '',
281
+ selector: typeof p.selector === 'string' ? p.selector : '',
282
+ };
283
+ if (typeof p.sourceLine === 'number') payload.sourceLine = p.sourceLine;
284
+ if (p.target && typeof p.target === 'object') payload.target = p.target;
285
+ return payload;
286
+ }
287
+
288
+ // Snapshot for GET /history (browser reconnect/rehydrate) — contract §3.3.
289
+ historySnapshot(aiSessionId) {
290
+ const review = this._reviews.get(aiSessionId);
291
+ if (!review) return null;
292
+ return {
293
+ chat: this._deriveChat(review),
294
+ events: cloneArray(review.events),
295
+ status: review.status,
296
+ cursor: String(review._seq || 0),
297
+ visibility: review.visibility === 'dismissed' ? 'dismissed' : 'shown',
298
+ };
299
+ }
300
+
301
+ // Typed events with id > cursor, filtered to the kinds a channel consumes.
302
+ eventsSince(aiSessionId, cursor, kinds) {
303
+ const review = this._reviews.get(aiSessionId);
304
+ if (!review) return [];
305
+ const after = Number(cursor) || 0;
306
+ const want = Array.isArray(kinds) ? kinds : null;
307
+ return review.events.filter((e) => {
308
+ if ((Number(e.id) || 0) <= after) return false;
309
+ return !want || want.includes(e.kind);
310
+ });
311
+ }
312
+
313
+ setVisibility(aiSessionId, visibility) {
314
+ const review = this._reviews.get(aiSessionId);
315
+ if (!review) return null;
316
+ review.visibility = visibility === 'dismissed' ? 'dismissed' : 'shown';
317
+ review.updatedAt = nowIso();
318
+ return review;
319
+ }
320
+
191
321
  queuePrompts(aiSessionId, prompts, domSnapshot) {
192
322
  const review = this._reviews.get(aiSessionId);
193
323
  if (!review) return null;
@@ -195,6 +325,12 @@ class ArtifactReviewStore extends EventEmitter {
195
325
  const queued = cloneArray(prompts);
196
326
  if (queued.length > 0) {
197
327
  review.queuedPrompts.push(...queued);
328
+ // Record each as a typed comment event for /history + /await (non-destructive;
329
+ // independent of the destructive queuedPrompts/poll path).
330
+ for (const p of queued) {
331
+ const payload = this._commentPayloadFromPrompt(p);
332
+ if (payload) this._appendEvent(review, payload);
333
+ }
198
334
  }
199
335
  if (domSnapshot !== undefined) {
200
336
  review.domSnapshot = domSnapshot;
@@ -207,6 +343,26 @@ class ArtifactReviewStore extends EventEmitter {
207
343
  return review;
208
344
  }
209
345
 
346
+ // Enqueue structured action events (contract §2/§4). Actions live ONLY in the
347
+ // typed event log (drain via /await + /history); they are NOT in the legacy
348
+ // destructive queue, so /poll never sees them (new typed lane). Returns the
349
+ // stored events (with ids). Emits 'feedback' so an in-flight /await resolves.
350
+ enqueueAction(aiSessionId, actions) {
351
+ const review = this._reviews.get(aiSessionId);
352
+ if (!review) return null;
353
+ const list = Array.isArray(actions) ? actions : [actions];
354
+ const stored = [];
355
+ for (const a of list) {
356
+ const payload = normalizeActionPayload(a);
357
+ if (payload) stored.push(this._appendEvent(review, payload));
358
+ }
359
+ if (stored.length > 0) {
360
+ review.updatedAt = nowIso();
361
+ this.emit('feedback', { aiSessionId, kind: 'actions', review });
362
+ }
363
+ return stored;
364
+ }
365
+
210
366
  recordLayoutWarnings(aiSessionId, warnings) {
211
367
  const review = this._reviews.get(aiSessionId);
212
368
  if (!review) return null;
@@ -299,15 +455,15 @@ class ArtifactReviewStore extends EventEmitter {
299
455
  const review = this._reviews.get(aiSessionId);
300
456
  if (!review) return null;
301
457
 
302
- const reply = {
303
- role: 'agent',
458
+ const event = this._appendEvent(review, {
459
+ kind: 'agent-reply',
304
460
  text: text == null ? '' : String(text),
305
- at: nowIso(),
306
- };
461
+ });
462
+ const reply = { role: 'agent', text: event.text, at: event.at, id: event.id };
307
463
  review.chat.push(reply);
308
464
  review.updatedAt = nowIso();
309
465
 
310
- this.emit('agent-reply', { aiSessionId, text: reply.text, reply, review });
466
+ this.emit('agent-reply', { aiSessionId, id: event.id, text: reply.text, reply, review });
311
467
  return reply;
312
468
  }
313
469
 
@@ -327,7 +483,8 @@ class ArtifactReviewStore extends EventEmitter {
327
483
 
328
484
  review.status = 'ended';
329
485
  review.updatedAt = nowIso();
330
- this.emit('ended', { aiSessionId, review });
486
+ const event = this._appendEvent(review, { kind: 'ended' });
487
+ this.emit('ended', { aiSessionId, id: event.id, review });
331
488
  return review;
332
489
  }
333
490
 
@@ -659,6 +816,14 @@ function createArtifactReviewRouter(options) {
659
816
  const pushTimeoutMs = typeof options.pushTimeoutMs === 'number' && options.pushTimeoutMs > 0
660
817
  ? options.pushTimeoutMs
661
818
  : PUSH_TIMEOUT_MS;
819
+ // Optional structured-approval routing hook (contract §5 / C-P1-4). Given an
820
+ // approval-style action (approve/reject/choose) it attempts to deliver it via
821
+ // the control-plane respond path (answering a pending ExitPlanMode/AskUser/
822
+ // permission menu). Returns truthy when it actually responded; then the action
823
+ // is NOT enqueued for the drain (no double-apply). Absent → all actions drain.
824
+ const routeApprovalAction = typeof options.routeApprovalAction === 'function'
825
+ ? options.routeApprovalAction
826
+ : null;
662
827
 
663
828
  // Count of in-flight long-poll requests per session. Non-zero means the agent
664
829
  // is actively waiting on artifact_poll, so a queued prompt is delivered by that
@@ -900,6 +1065,88 @@ function createArtifactReviewRouter(options) {
900
1065
  res.json({ ok: true, changed: !!(result && result.changed) });
901
1066
  });
902
1067
 
1068
+ // Structured action feedback (human→agent) — contract §3/§4. Actions are a typed
1069
+ // lane distinct from free-text /prompts: they are NEVER bracketed-paste-injected.
1070
+ // Approval-style actions (approve/reject/choose) route to the control-plane
1071
+ // respond path when a menu is pending (contract §5); everything else is enqueued
1072
+ // as a typed `action` event for the /await drain. Returns { ok, pushed, queued }.
1073
+ router.post('/:sessionId/actions', async (req, res) => {
1074
+ const sessionId = req.params.sessionId;
1075
+ if (!store.get(sessionId)) return res.status(404).json({ error: 'artifact review not found' });
1076
+
1077
+ const actions = req.body && req.body.actions;
1078
+ if (!Array.isArray(actions)) return res.status(400).json({ error: 'actions must be an array' });
1079
+
1080
+ let routed = 0;
1081
+ const toQueue = [];
1082
+ for (const raw of actions) {
1083
+ const norm = normalizeActionPayload(raw);
1084
+ if (!norm) continue;
1085
+ let handled = false;
1086
+ if (routeApprovalAction && isApprovalActionVerb(norm.action)) {
1087
+ try {
1088
+ handled = !!(await routeApprovalAction(sessionId, norm));
1089
+ } catch (_) {
1090
+ handled = false;
1091
+ }
1092
+ }
1093
+ if (handled) routed += 1;
1094
+ else toQueue.push(norm); // not routed (no live menu) → deliver via the drain so
1095
+ // the human's intent is never lost. routed and drained
1096
+ // are mutually exclusive per action, so no double-apply.
1097
+ }
1098
+ if (toQueue.length > 0) store.enqueueAction(sessionId, toQueue);
1099
+
1100
+ res.json({ ok: true, pushed: routed > 0, queued: toQueue.length });
1101
+ });
1102
+
1103
+ // Agent replaces the review's content (contract §1.1/§3.2). `file` (validatePath)
1104
+ // is re-read; `html` is written to the review's EXISTING sandboxed file, then a
1105
+ // reload is broadcast (never render an unsandboxed over-the-wire blob). Exactly
1106
+ // one of file|html; violations return the tagged-400 INVALID_REQUEST wire signal.
1107
+ router.post('/:sessionId/update', (req, res) => {
1108
+ const sessionId = req.params.sessionId;
1109
+ const review = store.get(sessionId);
1110
+ if (!review) return res.status(404).json({ error: 'artifact review not found' });
1111
+
1112
+ const file = req.body && req.body.file;
1113
+ const html = req.body && req.body.html;
1114
+ const hasFile = typeof file === 'string' && file.length > 0;
1115
+ const hasHtml = typeof html === 'string';
1116
+ if (hasFile === hasHtml) {
1117
+ return res.status(400).json({ error: { code: 'INVALID_REQUEST', message: 'exactly one of file|html is required' } });
1118
+ }
1119
+
1120
+ let targetPath;
1121
+ if (hasFile) {
1122
+ const validation = validatePath(file);
1123
+ if (!validation || !validation.valid) {
1124
+ return res.status(400).json({ error: { code: 'INVALID_REQUEST', message: (validation && validation.error) || 'file escapes the sandbox' } });
1125
+ }
1126
+ targetPath = validation.path;
1127
+ } else {
1128
+ // Write html to the review's existing (already-sandboxed) file.
1129
+ if (!review.file) {
1130
+ return res.status(400).json({ error: { code: 'INVALID_REQUEST', message: 'html requires an existing review file' } });
1131
+ }
1132
+ const validation = validatePath(review.file);
1133
+ if (!validation || !validation.valid) {
1134
+ return res.status(400).json({ error: { code: 'INVALID_REQUEST', message: (validation && validation.error) || 'review file escapes the sandbox' } });
1135
+ }
1136
+ try {
1137
+ fs.writeFileSync(validation.path, html, 'utf8');
1138
+ } catch (err) {
1139
+ return res.status(500).json({ error: 'write failed', message: err.message });
1140
+ }
1141
+ targetPath = validation.path;
1142
+ }
1143
+
1144
+ review.file = targetPath;
1145
+ startWatch(sessionId, targetPath);
1146
+ broadcastToSession(sessionId, { type: 'artifact_review_reload', sessionId });
1147
+ res.json({ ok: true, viewUrl: artifactPath(sessionId, '/view', req) });
1148
+ });
1149
+
903
1150
  router.get('/:sessionId/events', (req, res) => {
904
1151
  const sessionId = req.params.sessionId;
905
1152
  if (!store.get(sessionId)) return res.status(404).json({ error: 'artifact review not found' });
@@ -913,9 +1160,10 @@ function createArtifactReviewRouter(options) {
913
1160
  if (typeof res.flushHeaders === 'function') res.flushHeaders();
914
1161
 
915
1162
  let closed = false;
916
- function send(event, obj) {
1163
+ function send(event, obj, id) {
917
1164
  if (closed) return;
918
1165
  try {
1166
+ if (id != null) res.write('id: ' + id + '\n');
919
1167
  res.write('event: ' + event + '\n');
920
1168
  res.write('data: ' + JSON.stringify(obj) + '\n\n');
921
1169
  } catch (_) {
@@ -924,7 +1172,7 @@ function createArtifactReviewRouter(options) {
924
1172
  }
925
1173
  function onAgentReply(evt) {
926
1174
  if (!evt || evt.aiSessionId !== sessionId) return;
927
- send('agent-reply', { type: 'agent-reply', text: evt.text, reply: evt.reply });
1175
+ send('agent-reply', { type: 'agent-reply', id: evt.id, text: evt.text, reply: evt.reply }, evt.id);
928
1176
  }
929
1177
  function onPresence(evt) {
930
1178
  if (!evt || evt.aiSessionId !== sessionId) return;
@@ -932,7 +1180,7 @@ function createArtifactReviewRouter(options) {
932
1180
  }
933
1181
  function onEnded(evt) {
934
1182
  if (!evt || evt.aiSessionId !== sessionId) return;
935
- send('ended', { type: 'ended', status: 'ended' });
1183
+ send('ended', { type: 'ended', status: 'ended', id: evt.id }, evt.id);
936
1184
  cleanup();
937
1185
  try { res.end(); } catch (_) { /* ignore */ }
938
1186
  }
@@ -953,6 +1201,22 @@ function createArtifactReviewRouter(options) {
953
1201
  }, sseHeartbeatMs);
954
1202
  if (typeof heartbeat.unref === 'function') heartbeat.unref();
955
1203
 
1204
+ // Reconnect replay (C-P0-4): the browser's EventSource resends the last id it
1205
+ // saw via Last-Event-ID; replay exactly the gap (agent-reply / ended events
1206
+ // with a higher id) so a dropped SSE never loses a delivered reply. A fresh
1207
+ // connection (no header) replays nothing; the panel rehydrates via /history.
1208
+ const lastEventId = req.headers['last-event-id'];
1209
+ if (lastEventId != null && String(lastEventId).trim() !== '') {
1210
+ const missed = store.eventsSince(sessionId, lastEventId, ['agent-reply', 'ended']);
1211
+ for (const e of missed) {
1212
+ if (e.kind === 'agent-reply') {
1213
+ send('agent-reply', { type: 'agent-reply', id: e.id, text: e.text, reply: { role: 'agent', text: e.text, at: e.at, id: e.id } }, e.id);
1214
+ } else if (e.kind === 'ended') {
1215
+ send('ended', { type: 'ended', status: 'ended', id: e.id }, e.id);
1216
+ }
1217
+ }
1218
+ }
1219
+
956
1220
  const presence = store.setPresence(sessionId, { connected: true, lastSeen: nowIso() });
957
1221
  send('presence', { type: 'presence', presence });
958
1222
 
@@ -971,6 +1235,103 @@ function createArtifactReviewRouter(options) {
971
1235
  res.json({ ok: true, status: review.status });
972
1236
  });
973
1237
 
1238
+ // Hide/show the panel without ending the review (contract §9). Server-authoritative
1239
+ // visibility so a reconnecting panel and the artifact_dismiss tool agree. Body
1240
+ // { dismissed?: boolean } defaults true; the panel sends dismissed:false to
1241
+ // re-open. The review stays alive and the feedback channel stays open.
1242
+ router.post('/:sessionId/dismiss', (req, res) => {
1243
+ const sessionId = req.params.sessionId;
1244
+ if (!store.get(sessionId)) return res.status(404).json({ error: 'artifact review not found' });
1245
+ const dismissed = !(req.body && req.body.dismissed === false);
1246
+ store.setVisibility(sessionId, dismissed ? 'dismissed' : 'shown');
1247
+ broadcastToSession(sessionId, { type: 'artifact_review_dismissed', sessionId, dismissed });
1248
+ res.json({ ok: true, visibility: dismissed ? 'dismissed' : 'shown' });
1249
+ });
1250
+
1251
+ // Browser-only rehydrate/replay (contract §3.3). Returns the derived chat, the
1252
+ // typed event buffer, status, high-water cursor, and panel visibility.
1253
+ router.get('/:sessionId/history', (req, res) => {
1254
+ const sessionId = req.params.sessionId;
1255
+ const snapshot = store.historySnapshot(sessionId);
1256
+ if (!snapshot) return res.status(404).json({ error: 'artifact review not found' });
1257
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
1258
+ res.json(snapshot);
1259
+ });
1260
+
1261
+ // Typed drain (contract §1/§2). Long-polls like /poll, but returns
1262
+ // { events: ArtifactEvent[], status, cursor } — comment + ended events with
1263
+ // id > cursor. Single-delivery is by cursor (the agent passes the returned
1264
+ // cursor next call), so this is non-destructive w.r.t. the legacy /poll queue.
1265
+ // Counts as an active poll so concurrent /prompts feedback is delivered here,
1266
+ // never injected into the PTY.
1267
+ router.get('/:sessionId/await', (req, res) => {
1268
+ const sessionId = req.params.sessionId;
1269
+ const review = store.get(sessionId);
1270
+ if (!review) return res.status(404).json({ error: 'artifact review not found' });
1271
+
1272
+ const cursor = req.query && req.query.cursor != null ? String(req.query.cursor) : '0';
1273
+ const requestedHold = Number(req.query && req.query.timeoutMs);
1274
+ const holdMs = Number.isFinite(requestedHold) && requestedHold > 0
1275
+ ? Math.min(requestedHold, pollHoldMs)
1276
+ : pollHoldMs;
1277
+
1278
+ function awaitPayload() {
1279
+ const current = store.get(sessionId) || review;
1280
+ const events = store.eventsSince(sessionId, cursor, ['comment', 'action', 'ended']);
1281
+ const highWater = events.reduce((m, e) => Math.max(m, Number(e.id) || 0), Number(cursor) || 0);
1282
+ return { events, status: current ? current.status : 'missing', cursor: String(highWater) };
1283
+ }
1284
+
1285
+ const immediate = awaitPayload();
1286
+ if (immediate.events.length > 0 || review.status === 'ended') {
1287
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
1288
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
1289
+ return res.end(JSON.stringify(immediate));
1290
+ }
1291
+
1292
+ res.status(200);
1293
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
1294
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
1295
+ res.setHeader('X-Accel-Buffering', 'no');
1296
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
1297
+
1298
+ let pollCounted = true;
1299
+ pollDelta(sessionId, 1);
1300
+ let done = false;
1301
+ let timeout = null;
1302
+ let heartbeat = null;
1303
+ function cleanup() {
1304
+ if (pollCounted) { pollCounted = false; pollDelta(sessionId, -1); }
1305
+ if (timeout) clearTimeout(timeout);
1306
+ if (heartbeat) clearInterval(heartbeat);
1307
+ store.removeListener('feedback', onFeedback);
1308
+ store.removeListener('agent-reply', onAny);
1309
+ store.removeListener('ended', onAny);
1310
+ }
1311
+ function finish(payload) {
1312
+ if (done) return;
1313
+ done = true;
1314
+ cleanup();
1315
+ try { res.end(JSON.stringify(payload)); } catch (_) { /* client gone */ }
1316
+ }
1317
+ function tick() {
1318
+ const payload = awaitPayload();
1319
+ if (payload.events.length > 0) finish(payload);
1320
+ }
1321
+ function onFeedback(evt) { if (evt && evt.aiSessionId === sessionId) tick(); }
1322
+ function onAny(evt) { if (evt && evt.aiSessionId === sessionId) tick(); }
1323
+
1324
+ req.once('close', () => { if (!done) cleanup(); });
1325
+ timeout = setTimeout(() => finish(awaitPayload()), holdMs);
1326
+ heartbeat = setInterval(() => { try { res.write(' '); } catch (_) { cleanup(); } }, pollHeartbeatMs);
1327
+ if (typeof timeout.unref === 'function') timeout.unref();
1328
+ if (typeof heartbeat.unref === 'function') heartbeat.unref();
1329
+
1330
+ store.on('feedback', onFeedback);
1331
+ store.on('agent-reply', onAny);
1332
+ store.on('ended', onAny);
1333
+ });
1334
+
974
1335
  router.get('/:sessionId/poll', (req, res) => {
975
1336
  const sessionId = req.params.sessionId;
976
1337
  const review = store.get(sessionId);
@@ -1064,8 +1425,10 @@ function createArtifactReviewRouter(options) {
1064
1425
  const text = req.body && req.body.text;
1065
1426
  if (typeof text !== 'string') return res.status(400).json({ error: 'text is required' });
1066
1427
 
1428
+ // Render is SSE-only (contract §5 / C-P0-1): addAgentReply emits 'agent-reply'
1429
+ // which the /events stream delivers. We deliberately do NOT also broadcast a WS
1430
+ // artifact_agent_reply — that second path double-rendered the chat bubble.
1067
1431
  const reply = store.addAgentReply(sessionId, text);
1068
- broadcastToSession(sessionId, { type: 'artifact_agent_reply', sessionId, text });
1069
1432
  res.json({ ok: true, reply });
1070
1433
  });
1071
1434
 
@@ -1081,6 +1444,8 @@ module.exports = {
1081
1444
  formatFeedbackForAgent,
1082
1445
  buildArtifactPushPayload,
1083
1446
  artifactPushEnabledFromEnv,
1447
+ normalizeActionPayload,
1448
+ isApprovalActionVerb,
1084
1449
  injectLavishSdk,
1085
1450
  isMarkdownFile,
1086
1451
  markdownArtifactShell,
@@ -220,6 +220,66 @@
220
220
  return !annotationMode || isAnnotationUi(target) || isInteractiveControl(target);
221
221
  }
222
222
 
223
+ // ---- declarative interactive controls (data-aod-*; contract §4) ------------
224
+ // The producer renders buttons/plan-steps with data-aod-action / data-aod-id
225
+ // (+ optional data-aod-value / data-aod-group). The SDK captures activation and
226
+ // posts a structured 'artifact-action' to the panel — it does NOT open the
227
+ // annotation card. Native controls WITHOUT data-aod-action are untouched.
228
+
229
+ function aodActionEl(target) {
230
+ return target && target.closest ? target.closest('[data-aod-action]') : null;
231
+ }
232
+
233
+ // The currently-checked members of a multi-select group, as [{elementId,value?}].
234
+ function aodGroupChecked(group) {
235
+ var out = [];
236
+ if (!group) return out;
237
+ var boxes;
238
+ try {
239
+ boxes = document.querySelectorAll('[data-aod-action="check"][data-aod-group="' + cssEscape(group) + '"]');
240
+ } catch (_) {
241
+ boxes = [];
242
+ }
243
+ for (var i = 0; i < boxes.length; i++) {
244
+ var b = boxes[i];
245
+ if (!b.checked) continue;
246
+ var id = b.getAttribute('data-aod-id');
247
+ if (!id) continue;
248
+ var item = { elementId: id };
249
+ var v = b.getAttribute('data-aod-value');
250
+ if (v != null) item.value = v;
251
+ out.push(item);
252
+ }
253
+ return out;
254
+ }
255
+
256
+ // Emit a structured action for a data-aod element. Requires data-aod-action +
257
+ // data-aod-id (and data-aod-group for check/submit); missing → ignored. `check`
258
+ // toggles are UI-local and emit nothing (the group's submit harvests the set).
259
+ function emitAodAction(el) {
260
+ if (!el) return false;
261
+ var action = el.getAttribute('data-aod-action');
262
+ var elementId = el.getAttribute('data-aod-id');
263
+ if (!action || !elementId) return false;
264
+ if (action === 'check') return false; // UI-local only
265
+ var payload = { action: action, elementId: elementId };
266
+ var value = el.getAttribute('data-aod-value');
267
+ if (value != null) payload.value = value;
268
+ if (action === 'submit') {
269
+ var group = el.getAttribute('data-aod-group');
270
+ if (!group) return false; // submit requires a group
271
+ payload.group = group;
272
+ payload.selected = aodGroupChecked(group);
273
+ }
274
+ var ctx = { selector: selector(el), text: (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 240) };
275
+ var line = sourceLineOf(el);
276
+ if (line !== undefined) ctx.sourceLine = line;
277
+ payload.context = ctx;
278
+ payload.domSnapshot = readDomSnapshot();
279
+ post('artifact-action', payload);
280
+ return true;
281
+ }
282
+
223
283
  // ---- highlighting ----------------------------------------------------------
224
284
 
225
285
  function highlightElement(el) {
@@ -459,6 +519,25 @@
459
519
  if (data.type === 'presence') {
460
520
  window.dispatchEvent(new CustomEvent('ai-or-die-artifact-presence', { detail: data.payload || {} }));
461
521
  }
522
+ if (data.type === 'plan-state') {
523
+ // Reflect step/selection state onto the declared controls (contract §8) so
524
+ // the producer can style them via [data-aod-state]; also dispatch an event.
525
+ var steps = (data.payload && data.payload.steps) || [];
526
+ for (var i = 0; i < steps.length; i++) {
527
+ var s = steps[i];
528
+ if (!s || !s.elementId) continue;
529
+ var nodes;
530
+ try {
531
+ nodes = document.querySelectorAll('[data-aod-id="' + cssEscape(String(s.elementId)) + '"]');
532
+ } catch (_) {
533
+ nodes = [];
534
+ }
535
+ for (var j = 0; j < nodes.length; j++) {
536
+ if (s.state) nodes[j].setAttribute('data-aod-state', String(s.state));
537
+ }
538
+ }
539
+ window.dispatchEvent(new CustomEvent('ai-or-die-artifact-plan-state', { detail: data.payload || {} }));
540
+ }
462
541
  }
463
542
 
464
543
  window.addEventListener('message', handleHostMessage);
@@ -492,6 +571,7 @@
492
571
  }, true);
493
572
 
494
573
  document.addEventListener('mouseup', function (event) {
574
+ if (aodActionEl(event.target)) return; // interactive control, not an annotation target
495
575
  if (shouldIgnore(event.target)) return;
496
576
  var ctx = textSelectionContext(document.getSelection());
497
577
  if (!ctx) return;
@@ -500,6 +580,20 @@
500
580
  }, true);
501
581
 
502
582
  document.addEventListener('click', function (event) {
583
+ // Declarative interactive control: emit a structured action, not an annotation.
584
+ var aod = aodActionEl(event.target);
585
+ if (aod) {
586
+ var action = aod.getAttribute('data-aod-action');
587
+ var tag = (aod.tagName || '').toLowerCase();
588
+ if (action === 'check') return; // let the checkbox toggle natively; no emit
589
+ if (tag === 'select') return; // <select> emits via 'change' only (no double-emit)
590
+ // Suppress native navigation/submit for anchors + submit-style controls.
591
+ if (tag === 'a' || action === 'submit' || (tag === 'button' && aod.type === 'submit')) {
592
+ event.preventDefault();
593
+ }
594
+ emitAodAction(aod);
595
+ return;
596
+ }
503
597
  if (shouldIgnore(event.target)) return;
504
598
  event.preventDefault();
505
599
  event.stopPropagation();
@@ -510,6 +604,20 @@
510
604
  showAnnotationCard(event.target);
511
605
  }, true);
512
606
 
607
+ // A data-aod <select> signals via 'change' (its click already returned above, so
608
+ // there is no double-emit). `check` changes stay UI-local (harvested by submit);
609
+ // any other data-aod change on a non-select control also emits here as a fallback
610
+ // for controls that only fire 'change'.
611
+ document.addEventListener('change', function (event) {
612
+ var aod = aodActionEl(event.target);
613
+ if (!aod) return;
614
+ var action = aod.getAttribute('data-aod-action');
615
+ if (action === 'check') return; // UI-local
616
+ var tag = (aod.tagName || '').toLowerCase();
617
+ if (tag !== 'select') return; // non-selects already emitted on click
618
+ emitAodAction(aod);
619
+ }, true);
620
+
513
621
  // ---- legacy API (backward-compat) -----------------------------------------
514
622
  // The previous SDK exposed window.lavish + prompt/queuePrompts/ask/request/
515
623
  // warnLayout and posted the 'artifact-prompts' / 'artifact-layout-warnings'