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 +1 -1
- package/src/artifact-review.js +375 -10
- package/src/artifact-sdk-client.js +108 -0
- package/src/control/jsonl-awaiting.js +85 -24
- package/src/mesh-manager.js +28 -0
- package/src/public/app.js +6 -2
- package/src/public/artifact-panel.js +236 -38
- package/src/public/components/artifact-panel.css +24 -0
- package/src/server.js +66 -7
package/package.json
CHANGED
package/src/artifact-review.js
CHANGED
|
@@ -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
|
|
303
|
-
|
|
458
|
+
const event = this._appendEvent(review, {
|
|
459
|
+
kind: 'agent-reply',
|
|
304
460
|
text: text == null ? '' : String(text),
|
|
305
|
-
|
|
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.
|
|
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'
|