@hybridlabor-api/aos 4.0.2 → 4.1.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 (79) hide show
  1. package/.claude/agents/database-reviewer.md +109 -0
  2. package/.claude/agents/go-build-resolver.md +112 -0
  3. package/.claude/agents/opensource-forker.md +216 -0
  4. package/.claude/agents/opensource-sanitizer.md +206 -0
  5. package/.claude/agents/security-reviewer.md +126 -0
  6. package/.claude/agents/silent-failure-hunter.md +68 -0
  7. package/CLAUDE.md +15 -0
  8. package/README.md +0 -13
  9. package/THIRD_PARTY_NOTICES.md +133 -0
  10. package/package.json +4 -2
  11. package/skills/basic/bdbmediastorm/SKILL.md +7 -1
  12. package/skills/basic/startcycle/SKILL.md +2 -0
  13. package/skills/bdbrainstorm/SKILL.md +1 -0
  14. package/skills/global_config/plan-canvas/SKILL.md +233 -0
  15. package/skills/global_config/plan-canvas/scripts/lib/loopback-guard.js +59 -0
  16. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/markdown.js +301 -0
  17. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/sdk.js +239 -0
  18. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/server.js +636 -0
  19. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/sessions.js +271 -0
  20. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/ui.js +630 -0
  21. package/skills/global_config/plan-canvas/scripts/plan-canvas.js +419 -0
  22. package/.agents/skills/firecrawl/SKILL.md +0 -149
  23. package/.agents/skills/firecrawl/rules/install.md +0 -82
  24. package/.agents/skills/firecrawl/rules/security.md +0 -26
  25. package/.agents/skills/firecrawl-agent/SKILL.md +0 -58
  26. package/.agents/skills/firecrawl-build/SKILL.md +0 -39
  27. package/.agents/skills/firecrawl-build-interact/SKILL.md +0 -68
  28. package/.agents/skills/firecrawl-build-onboarding/SKILL.md +0 -103
  29. package/.agents/skills/firecrawl-build-onboarding/references/auth-flow.md +0 -39
  30. package/.agents/skills/firecrawl-build-onboarding/references/project-setup.md +0 -20
  31. package/.agents/skills/firecrawl-build-onboarding/references/sdk-installation.md +0 -17
  32. package/.agents/skills/firecrawl-build-scrape/SKILL.md +0 -69
  33. package/.agents/skills/firecrawl-build-search/SKILL.md +0 -69
  34. package/.agents/skills/firecrawl-crawl/SKILL.md +0 -59
  35. package/.agents/skills/firecrawl-download/SKILL.md +0 -70
  36. package/.agents/skills/firecrawl-interact/SKILL.md +0 -84
  37. package/.agents/skills/firecrawl-map/SKILL.md +0 -51
  38. package/.agents/skills/firecrawl-scrape/SKILL.md +0 -69
  39. package/.agents/skills/firecrawl-search/SKILL.md +0 -60
  40. package/docs/sessions/AUDIT-HANDOVER-2026-08-28.md +0 -169
  41. package/docs/sessions/BDB_REMOTEOS_MCP_HANDOVER.md +0 -130
  42. package/docs/sessions/SESSION-HANDOVER-v3.13.md +0 -249
  43. package/mcps/RhinoMCP/cc-plugin/.claude/settings.json +0 -10
  44. package/mcps/after-effects-mcp/build/index.js +0 -840
  45. package/mcps/after-effects-mcp/build/scripts/applyEffect.jsx +0 -153
  46. package/mcps/after-effects-mcp/build/scripts/applyEffectTemplate.jsx +0 -218
  47. package/mcps/after-effects-mcp/build/scripts/createComposition.jsx +0 -71
  48. package/mcps/after-effects-mcp/build/scripts/createShapeLayer.jsx +0 -147
  49. package/mcps/after-effects-mcp/build/scripts/createSolidLayer.jsx +0 -114
  50. package/mcps/after-effects-mcp/build/scripts/createTextLayer.jsx +0 -115
  51. package/mcps/after-effects-mcp/build/scripts/getLayerInfo.jsx +0 -192
  52. package/mcps/after-effects-mcp/build/scripts/getProjectInfo.jsx +0 -90
  53. package/mcps/after-effects-mcp/build/scripts/listCompositions.jsx +0 -50
  54. package/mcps/after-effects-mcp/build/scripts/mcp-bridge-auto.jsx +0 -1773
  55. package/mcps/after-effects-mcp/build/scripts/setLayerProperties.jsx +0 -160
  56. package/mcps/bdb-remoteos-mcp/queue.db +0 -0
  57. package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  58. package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/incus_client.cpython-312.pyc +0 -0
  59. package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/main.cpython-312.pyc +0 -0
  60. package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/queue.cpython-312.pyc +0 -0
  61. package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/schemas.cpython-312.pyc +0 -0
  62. package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/server.cpython-312.pyc +0 -0
  63. package/mcps/bdb-remoteos-mcp/src/bdb_remoteos_mcp/__pycache__/webhook.cpython-312.pyc +0 -0
  64. package/mcps/bdb-remoteos-mcp/tests/__pycache__/__init__.cpython-312.pyc +0 -0
  65. package/mcps/bdb-remoteos-mcp/tests/__pycache__/mock_incus.cpython-312.pyc +0 -0
  66. package/mcps/bdb-remoteos-mcp/tests/__pycache__/test_mcp_server.cpython-312-pytest-9.1.1.pyc +0 -0
  67. package/mcps/bdb-remoteos-mcp/tests/__pycache__/test_security_redteam.cpython-312-pytest-9.1.1.pyc +0 -0
  68. package/mcps/bdb-remoteos-mcp/tests/__pycache__/test_webhook.cpython-312-pytest-9.1.1.pyc +0 -0
  69. package/mcps/computer-use-mcp/dist/client.d.ts +0 -150
  70. package/mcps/computer-use-mcp/dist/client.js +0 -136
  71. package/mcps/computer-use-mcp/dist/entrypoint.d.ts +0 -16
  72. package/mcps/computer-use-mcp/dist/entrypoint.js +0 -26
  73. package/mcps/computer-use-mcp/dist/native.d.ts +0 -212
  74. package/mcps/computer-use-mcp/dist/native.js +0 -50
  75. package/mcps/computer-use-mcp/dist/server.d.ts +0 -32
  76. package/mcps/computer-use-mcp/dist/server.js +0 -342
  77. package/mcps/computer-use-mcp/dist/session.d.ts +0 -101
  78. package/mcps/computer-use-mcp/dist/session.js +0 -2372
  79. package/skills/bdbsaastraining/scripts/__pycache__/build_profile.cpython-314.pyc +0 -0
@@ -0,0 +1,636 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Plan Canvas loopback server.
5
+ *
6
+ * One detached process serves every open review session: the browser chrome,
7
+ * the rendered artifact, an SSE stream for live updates, and the long-poll
8
+ * endpoint agents block on. Sessions are keyed by canonical artifact path
9
+ * (see sessions.js).
10
+ *
11
+ * Source: affaan-m/ECC — MIT, see THIRD_PARTY_NOTICES.md
12
+ */
13
+
14
+ const { EventEmitter } = require('events');
15
+ const fs = require('fs');
16
+ const http = require('http');
17
+ const path = require('path');
18
+
19
+ const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
20
+ const { renderMarkdown } = require('./markdown');
21
+ const { artifactSdkJs } = require('./sdk');
22
+ const {
23
+ canvasCss,
24
+ canvasClientJs,
25
+ renderCanvasHtml,
26
+ renderMarkdownArtifactHtml,
27
+ renderSessionListHtml
28
+ } = require('./ui');
29
+
30
+ const DEFAULT_PORT = 4519;
31
+ const DEFAULT_HOST = '127.0.0.1';
32
+ const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
33
+ const MAX_BODY_BYTES = 1024 * 1024;
34
+ // How long the "agent is thinking" indicator survives without the agent
35
+ // checking back in, before presence decays to the honest queued/waiting.
36
+ const DEFAULT_THINKING_STALE_MS = 90 * 1000;
37
+ // An explicit typing signal expires faster: it means "a reply is seconds away".
38
+ const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000;
39
+ // Presence is push-based, so expiring states need a tick to re-broadcast on.
40
+ const DEFAULT_PRESENCE_SWEEP_MS = 5 * 1000;
41
+ const TYPING_STATES = new Set(['thinking', 'typing', 'idle']);
42
+
43
+ const CONTENT_TYPES = {
44
+ '.css': 'text/css; charset=utf-8',
45
+ '.gif': 'image/gif',
46
+ '.html': 'text/html; charset=utf-8',
47
+ '.ico': 'image/x-icon',
48
+ '.jpeg': 'image/jpeg',
49
+ '.jpg': 'image/jpeg',
50
+ '.js': 'text/javascript; charset=utf-8',
51
+ '.json': 'application/json; charset=utf-8',
52
+ '.md': 'text/plain; charset=utf-8',
53
+ '.mjs': 'text/javascript; charset=utf-8',
54
+ '.png': 'image/png',
55
+ '.svg': 'image/svg+xml',
56
+ '.ttf': 'font/ttf',
57
+ '.txt': 'text/plain; charset=utf-8',
58
+ '.webp': 'image/webp',
59
+ '.woff': 'font/woff',
60
+ '.woff2': 'font/woff2'
61
+ };
62
+
63
+ function resolvePort(env = process.env) {
64
+ const value = Number.parseInt(env.AOS_PLAN_CANVAS_PORT || '', 10);
65
+ return Number.isInteger(value) && value >= 0 && value <= 65535 ? value : DEFAULT_PORT;
66
+ }
67
+
68
+ function resolveIdleTimeoutMs(env = process.env) {
69
+ const raw = String(env.AOS_PLAN_CANVAS_IDLE_MS || '').trim().toLowerCase();
70
+ if (raw === '0' || raw === 'off') return 0;
71
+ const value = Number.parseInt(raw, 10);
72
+ return Number.isInteger(value) && value > 0 ? value : DEFAULT_IDLE_TIMEOUT_MS;
73
+ }
74
+
75
+ function readJsonBody(req) {
76
+ return new Promise((resolve, reject) => {
77
+ let size = 0;
78
+ const chunks = [];
79
+ req.on('data', chunk => {
80
+ size += chunk.length;
81
+ if (size > MAX_BODY_BYTES) {
82
+ reject(new Error('body too large'));
83
+ req.destroy();
84
+ return;
85
+ }
86
+ chunks.push(chunk);
87
+ });
88
+ req.on('end', () => {
89
+ if (chunks.length === 0) return resolve({});
90
+ try {
91
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
92
+ } catch {
93
+ reject(new Error('invalid JSON body'));
94
+ }
95
+ });
96
+ req.on('error', reject);
97
+ });
98
+ }
99
+
100
+ function sendJson(res, statusCode, payload) {
101
+ const body = JSON.stringify(payload);
102
+ res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
103
+ res.end(body);
104
+ }
105
+
106
+ function sendHtml(res, statusCode, html, { csp = true } = {}) {
107
+ const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
108
+ if (csp) {
109
+ headers['content-security-policy'] =
110
+ "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
111
+ }
112
+ res.writeHead(statusCode, headers);
113
+ res.end(html);
114
+ }
115
+
116
+ function createPlanCanvasServer({
117
+ store,
118
+ host = DEFAULT_HOST,
119
+ version = '0.0.0',
120
+ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
121
+ heartbeatMs = 15000,
122
+ thinkingStaleMs = DEFAULT_THINKING_STALE_MS,
123
+ typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS,
124
+ presenceSweepMs = DEFAULT_PRESENCE_SWEEP_MS,
125
+ onIdleShutdown = null,
126
+ log = () => {}
127
+ } = {}) {
128
+ if (!store) throw new Error('createPlanCanvasServer requires a session store');
129
+
130
+ const allowedHostnames = buildAllowedHostnames(host);
131
+ const wake = new EventEmitter();
132
+ wake.setMaxListeners(0);
133
+ const sseClients = new Map(); // key -> Set<res>
134
+ const awaitCounts = new Map(); // key -> active long-poll count
135
+ const workingKeys = new Map(); // key -> ms timestamp the agent took feedback
136
+ const typingKeys = new Map(); // key -> ms timestamp the agent signalled composing
137
+ const watchers = new Map(); // key -> fs.FSWatcher
138
+ const lastPresence = new Map(); // key -> last broadcast state, for sweep diffing
139
+ let idleTimer = null;
140
+ let presenceSweep = null;
141
+ let closed = false;
142
+
143
+ // --- presence + SSE ---------------------------------------------------
144
+
145
+ /**
146
+ * Presence never claims more than the server actually knows:
147
+ *
148
+ * ended session is closed
149
+ * typing agent signalled it is composing a reply (self-expiring)
150
+ * thinking agent took the feedback and is working on it (self-expiring)
151
+ * listening an `await` long poll is parked on this session right now
152
+ * queued feedback is sitting undelivered with nobody listening
153
+ * waiting nothing queued, nobody listening
154
+ *
155
+ * `thinking` and `typing` expire on their own so a crashed or distracted
156
+ * agent decays to an honest `queued`/`waiting` instead of spinning forever.
157
+ * The old `working` pill had no expiry and no re-broadcast, so it stuck at
158
+ * "agent working" while nothing at all was listening.
159
+ */
160
+ function presenceFor(key, now = Date.now()) {
161
+ const session = store.get(key);
162
+ if (!session || session.status === 'ended') return 'ended';
163
+ const typingAt = typingKeys.get(key);
164
+ if (typingAt !== undefined && now - typingAt < typingExpiryMs) return 'typing';
165
+ const workingAt = workingKeys.get(key);
166
+ if (workingAt !== undefined && now - workingAt < thinkingStaleMs) return 'thinking';
167
+ if ((awaitCounts.get(key) || 0) > 0) return 'listening';
168
+ return session.pendingFeedback && session.pendingFeedback.length > 0 ? 'queued' : 'waiting';
169
+ }
170
+
171
+ function broadcast(key, event, payload) {
172
+ const clients = sseClients.get(key);
173
+ if (!clients) return;
174
+ const frameText = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
175
+ for (const client of clients) client.write(frameText);
176
+ }
177
+
178
+ function broadcastPresence(key) {
179
+ const state = presenceFor(key);
180
+ lastPresence.set(key, state);
181
+ broadcast(key, 'presence', { state });
182
+ }
183
+
184
+ // Re-broadcast only where an expiry actually changed the answer, so an
185
+ // untouched canvas sees the thinking bubble clear itself.
186
+ function sweepPresence() {
187
+ for (const key of sseClients.keys()) {
188
+ const state = presenceFor(key);
189
+ if (lastPresence.get(key) !== state) broadcastPresence(key);
190
+ }
191
+ }
192
+
193
+ function startPresenceSweep() {
194
+ if (presenceSweep || !presenceSweepMs) return;
195
+ presenceSweep = setInterval(sweepPresence, presenceSweepMs);
196
+ if (presenceSweep.unref) presenceSweep.unref();
197
+ }
198
+
199
+ // The agent is off working on this feedback batch; start the thinking clock.
200
+ function markThinking(key) {
201
+ workingKeys.set(key, Date.now());
202
+ typingKeys.delete(key);
203
+ }
204
+
205
+ // A reply landed (or the agent picked the session back up): stop pretending.
206
+ function clearAgentActivity(key) {
207
+ workingKeys.delete(key);
208
+ typingKeys.delete(key);
209
+ }
210
+
211
+ function connectionCount() {
212
+ let total = 0;
213
+ for (const clients of sseClients.values()) total += clients.size;
214
+ for (const count of awaitCounts.values()) total += count;
215
+ return total;
216
+ }
217
+
218
+ function armIdleTimer() {
219
+ if (!idleTimeoutMs || closed) return;
220
+ if (connectionCount() > 0) return;
221
+ clearTimeout(idleTimer);
222
+ idleTimer = setTimeout(() => {
223
+ if (connectionCount() === 0 && !closed) {
224
+ log('[plan-canvas] idle timeout reached, shutting down');
225
+ if (onIdleShutdown) onIdleShutdown();
226
+ }
227
+ }, idleTimeoutMs);
228
+ if (idleTimer.unref) idleTimer.unref();
229
+ }
230
+
231
+ function noteConnectionOpened() {
232
+ clearTimeout(idleTimer);
233
+ }
234
+
235
+ function noteConnectionClosed() {
236
+ armIdleTimer();
237
+ }
238
+
239
+ // --- artifact watching --------------------------------------------------
240
+
241
+ function watchSession(session) {
242
+ if (watchers.has(session.key)) return;
243
+ const dir = path.dirname(session.file);
244
+ const base = path.basename(session.file);
245
+ let debounce = null;
246
+ try {
247
+ const watcher = fs.watch(dir, (eventType, filename) => {
248
+ if (filename && filename !== base) return;
249
+ clearTimeout(debounce);
250
+ debounce = setTimeout(() => broadcast(session.key, 'reload', {}), 150);
251
+ });
252
+ watcher.on('error', () => watchers.delete(session.key));
253
+ watchers.set(session.key, watcher);
254
+ } catch {
255
+ // Watching is best-effort; manual reload still works.
256
+ }
257
+ }
258
+
259
+ function unwatchSession(key) {
260
+ const watcher = watchers.get(key);
261
+ if (watcher) {
262
+ watcher.close();
263
+ watchers.delete(key);
264
+ }
265
+ }
266
+
267
+ // --- session actions ------------------------------------------------------
268
+
269
+ function endSession(key, endedBy) {
270
+ const session = store.end(key, endedBy);
271
+ if (!session) return null;
272
+ clearAgentActivity(key);
273
+ wake.emit(`wake:${key}`);
274
+ broadcast(key, 'ended', { endedBy: session.endedBy });
275
+ broadcastPresence(key);
276
+ unwatchSession(key);
277
+ return session;
278
+ }
279
+
280
+ // --- request handlers -------------------------------------------------------
281
+
282
+ async function handleApi(req, res, url) {
283
+ const { pathname } = url;
284
+
285
+ if (req.method === 'POST' && pathname === '/api/sessions') {
286
+ const body = await readJsonBody(req);
287
+ if (!body.file || typeof body.file !== 'string') {
288
+ return sendJson(res, 400, { error: 'file is required' });
289
+ }
290
+ if (!fs.existsSync(path.resolve(body.file))) {
291
+ return sendJson(res, 404, { error: `artifact not found: ${body.file}` });
292
+ }
293
+ const { session, refused } = store.open(body.file, { reopen: Boolean(body.reopen) });
294
+ if (refused) {
295
+ return sendJson(res, 409, {
296
+ status: 'user-ended',
297
+ key: session.key,
298
+ next_step: 'The user ended this review from the browser. Do not reopen it unless they ask; pass reopen:true when they do.'
299
+ });
300
+ }
301
+ watchSession(session);
302
+ broadcastPresence(session.key);
303
+ return sendJson(res, 200, {
304
+ status: 'open',
305
+ key: session.key,
306
+ file: session.file,
307
+ url: `/canvas/${session.key}`
308
+ });
309
+ }
310
+
311
+ if (req.method === 'GET' && pathname === '/api/sessions') {
312
+ return sendJson(res, 200, { sessions: store.list() });
313
+ }
314
+
315
+ if (req.method === 'GET' && pathname === '/api/await') {
316
+ const keyParam = url.searchParams.get('key');
317
+ const file = url.searchParams.get('file');
318
+ if (keyParam && !/^[a-f0-9]{12}$/.test(keyParam)) return sendJson(res, 400, { error: 'invalid session key' });
319
+ if (!keyParam && !file) return sendJson(res, 400, { error: 'key or file query parameter is required' });
320
+ const session = keyParam ? store.get(keyParam) : store.findByFile(file);
321
+ if (!session) return sendJson(res, 200, { status: 'missing' });
322
+ const key = session.key;
323
+ const timeoutRaw = url.searchParams.get('timeoutMs');
324
+ const timeoutMs = timeoutRaw === null ? null : Math.max(0, Number.parseInt(timeoutRaw, 10) || 0);
325
+
326
+ const first = store.takeFeedback(key);
327
+ if (first.status !== 'waiting') {
328
+ if (first.status === 'feedback') markThinking(key);
329
+ broadcastPresence(key);
330
+ return sendJson(res, 200, first);
331
+ }
332
+
333
+ // Long poll: hold the request open until feedback or session end.
334
+ noteConnectionOpened();
335
+ awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1);
336
+ clearAgentActivity(key);
337
+ broadcastPresence(key);
338
+
339
+ let settled = false;
340
+ let heartbeat = null;
341
+ let waitTimer = null;
342
+ const finish = payload => {
343
+ if (settled) return;
344
+ settled = true;
345
+ cleanup();
346
+ if (payload) {
347
+ if (payload.status === 'feedback') markThinking(key);
348
+ res.end(JSON.stringify(payload));
349
+ }
350
+ broadcastPresence(key);
351
+ noteConnectionClosed();
352
+ };
353
+ const onWake = () => {
354
+ const result = store.takeFeedback(key);
355
+ if (result.status !== 'waiting') finish(result);
356
+ };
357
+ // Settle held polls on shutdown so server.close() can complete; the
358
+ // CLI tells agents to simply re-run await.
359
+ const onServerClose = () =>
360
+ finish({ status: 'waiting', note: 'canvas server is shutting down; re-run await' });
361
+ const cleanup = () => {
362
+ wake.removeListener(`wake:${key}`, onWake);
363
+ wake.removeListener('server-close', onServerClose);
364
+ clearInterval(heartbeat);
365
+ clearTimeout(waitTimer);
366
+ awaitCounts.set(key, Math.max(0, (awaitCounts.get(key) || 1) - 1));
367
+ };
368
+
369
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
370
+ // Leading whitespace keeps the connection visibly alive without
371
+ // corrupting the JSON payload written at the end.
372
+ res.write(' ');
373
+ heartbeat = setInterval(() => {
374
+ if (!settled) res.write(' ');
375
+ }, heartbeatMs);
376
+ if (timeoutMs !== null) {
377
+ waitTimer = setTimeout(() => finish({ status: 'waiting' }), timeoutMs);
378
+ }
379
+ wake.on(`wake:${key}`, onWake);
380
+ wake.once('server-close', onServerClose);
381
+ req.on('close', () => finish(null));
382
+ return undefined;
383
+ }
384
+
385
+ if (req.method === 'POST' && pathname === '/api/end') {
386
+ const body = await readJsonBody(req);
387
+ if (!body.file || typeof body.file !== 'string') {
388
+ return sendJson(res, 400, { error: 'file is required' });
389
+ }
390
+ const session = store.findByFile(body.file);
391
+ if (!session) return sendJson(res, 404, { error: 'no session for that file' });
392
+ endSession(session.key, 'agent');
393
+ return sendJson(res, 200, { status: 'ended', endedBy: 'agent' });
394
+ }
395
+
396
+ const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/);
397
+ if (sessionMatch && req.method === 'POST') {
398
+ const [, key, action] = sessionMatch;
399
+ const session = store.get(key);
400
+ if (!session) return sendJson(res, 404, { error: 'unknown session' });
401
+
402
+ if (action === 'feedback') {
403
+ const body = await readJsonBody(req);
404
+ const result = store.queueFeedback(key, body.items, { endSession: Boolean(body.endSession) });
405
+ if (!result) return sendJson(res, 409, { error: 'session already ended' });
406
+ wake.emit(`wake:${key}`);
407
+ broadcast(key, 'chat-sync', { chat: store.get(key).chat });
408
+ if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' });
409
+ // A parked `await` takes the batch synchronously on the wake above, so
410
+ // presence is already `thinking` by now; with nobody listening it
411
+ // reports `queued`. Either way the browser must be told, which the
412
+ // original handler never did, leaving a stale pill on screen.
413
+ broadcastPresence(key);
414
+ return sendJson(res, 200, {
415
+ status: 'queued',
416
+ accepted: result.accepted.length,
417
+ pending: result.pending,
418
+ presence: presenceFor(key)
419
+ });
420
+ }
421
+
422
+ if (action === 'end') {
423
+ endSession(key, 'user');
424
+ return sendJson(res, 200, { status: 'ended', endedBy: 'user' });
425
+ }
426
+
427
+ if (action === 'reply') {
428
+ const body = await readJsonBody(req);
429
+ if (!body.text || typeof body.text !== 'string') {
430
+ return sendJson(res, 400, { error: 'text is required' });
431
+ }
432
+ const entry = store.addAgentReply(key, body.text);
433
+ clearAgentActivity(key);
434
+ broadcast(key, 'chat-sync', { chat: store.get(key).chat });
435
+ broadcastPresence(key);
436
+ return sendJson(res, 200, { status: 'sent', at: entry.at });
437
+ }
438
+
439
+ // Agents drive the chat indicator explicitly: `thinking` while they work,
440
+ // `typing` right before a reply lands, `idle` to take the bubble down.
441
+ if (action === 'typing') {
442
+ const body = await readJsonBody(req);
443
+ const state = typeof body.state === 'string' ? body.state : 'typing';
444
+ if (!TYPING_STATES.has(state)) {
445
+ return sendJson(res, 400, { error: `state must be one of: ${[...TYPING_STATES].join(', ')}` });
446
+ }
447
+ if (state === 'idle') clearAgentActivity(key);
448
+ else if (state === 'typing') typingKeys.set(key, Date.now());
449
+ else markThinking(key);
450
+ broadcastPresence(key);
451
+ return sendJson(res, 200, { status: 'ok', presence: presenceFor(key) });
452
+ }
453
+ }
454
+
455
+ return sendJson(res, 404, { error: 'not found' });
456
+ }
457
+
458
+ function handleEvents(req, res, key) {
459
+ const session = store.get(key);
460
+ if (!session) return sendJson(res, 404, { error: 'unknown session' });
461
+ noteConnectionOpened();
462
+ res.writeHead(200, {
463
+ 'content-type': 'text/event-stream',
464
+ 'cache-control': 'no-store',
465
+ connection: 'keep-alive'
466
+ });
467
+ res.write(`event: chat-sync\ndata: ${JSON.stringify({ chat: session.chat })}\n\n`);
468
+ res.write(`event: presence\ndata: ${JSON.stringify({ state: presenceFor(key) })}\n\n`);
469
+ if (!sseClients.has(key)) sseClients.set(key, new Set());
470
+ sseClients.get(key).add(res);
471
+ lastPresence.set(key, presenceFor(key));
472
+ startPresenceSweep();
473
+ const ping = setInterval(() => res.write(': ping\n\n'), 25000);
474
+ if (ping.unref) ping.unref();
475
+ req.on('close', () => {
476
+ clearInterval(ping);
477
+ const clients = sseClients.get(key);
478
+ if (clients) {
479
+ clients.delete(res);
480
+ if (clients.size === 0) {
481
+ sseClients.delete(key);
482
+ lastPresence.delete(key);
483
+ }
484
+ }
485
+ noteConnectionClosed();
486
+ });
487
+ }
488
+
489
+ function serveArtifact(res, key, assetPath) {
490
+ const session = store.get(key);
491
+ if (!session) return sendHtml(res, 404, '<h1>Unknown session</h1>');
492
+
493
+ if (!assetPath) {
494
+ let content;
495
+ try {
496
+ content = fs.readFileSync(session.file, 'utf8');
497
+ } catch {
498
+ return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${session.file} no longer exists.</p>`, { csp: false });
499
+ }
500
+ const ext = path.extname(session.file).toLowerCase();
501
+ if (ext === '.md' || ext === '.markdown') {
502
+ const html = renderMarkdownArtifactHtml(renderMarkdown(content), {
503
+ title: path.basename(session.file),
504
+ sdkSrc: '/sdk.js'
505
+ });
506
+ return sendHtml(res, 200, html, { csp: false });
507
+ }
508
+ const sdkTag = '<script src="/sdk.js"></script>';
509
+ const injected = content.includes('</body>')
510
+ ? content.replace('</body>', `${sdkTag}\n</body>`)
511
+ : `${content}\n${sdkTag}`;
512
+ return sendHtml(res, 200, injected, { csp: false });
513
+ }
514
+
515
+ // Sibling assets resolve relative to the artifact's directory and must
516
+ // stay confined to it.
517
+ const baseDir = path.dirname(session.file);
518
+ const resolved = path.resolve(baseDir, assetPath);
519
+ if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) {
520
+ return sendJson(res, 403, { error: 'asset path escapes artifact directory' });
521
+ }
522
+ let data;
523
+ try {
524
+ data = fs.readFileSync(resolved);
525
+ } catch {
526
+ return sendJson(res, 404, { error: 'asset not found' });
527
+ }
528
+ const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
529
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
530
+ return res.end(data);
531
+ }
532
+
533
+ const server = http.createServer((req, res) => {
534
+ if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
535
+ return sendJson(res, 403, { error: 'forbidden host header' });
536
+ }
537
+ if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) {
538
+ return sendJson(res, 403, { error: 'forbidden origin' });
539
+ }
540
+ const url = new URL(req.url, `http://${req.headers.host}`);
541
+ const { pathname } = url;
542
+
543
+ Promise.resolve()
544
+ .then(() => {
545
+ if (req.method === 'GET' && pathname === '/health') {
546
+ return sendJson(res, 200, { ok: true, app: 'aos-plan-canvas', version });
547
+ }
548
+ if (req.method === 'POST' && pathname === '/shutdown') {
549
+ sendJson(res, 200, { status: 'stopping' });
550
+ setImmediate(() => {
551
+ if (onIdleShutdown) onIdleShutdown();
552
+ });
553
+ return undefined;
554
+ }
555
+ if (req.method === 'GET' && pathname === '/') {
556
+ return sendHtml(res, 200, renderSessionListHtml(store.list()));
557
+ }
558
+ if (req.method === 'GET' && pathname === '/canvas.css') {
559
+ res.writeHead(200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-store' });
560
+ return res.end(canvasCss());
561
+ }
562
+ if (req.method === 'GET' && pathname === '/client.js') {
563
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
564
+ return res.end(canvasClientJs());
565
+ }
566
+ if (req.method === 'GET' && pathname === '/sdk.js') {
567
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
568
+ return res.end(artifactSdkJs());
569
+ }
570
+ const canvasMatch = pathname.match(/^\/canvas\/([a-f0-9]{12})$/);
571
+ if (req.method === 'GET' && canvasMatch) {
572
+ const session = store.get(canvasMatch[1]);
573
+ if (!session) return sendHtml(res, 404, '<h1>Unknown session</h1>');
574
+ return sendHtml(res, 200, renderCanvasHtml(session));
575
+ }
576
+ const eventsMatch = pathname.match(/^\/events\/([a-f0-9]{12})$/);
577
+ if (req.method === 'GET' && eventsMatch) {
578
+ return handleEvents(req, res, eventsMatch[1]);
579
+ }
580
+ const artifactMatch = pathname.match(/^\/artifact\/([a-f0-9]{12})\/(.*)$/);
581
+ if (req.method === 'GET' && artifactMatch) {
582
+ const assetPath = decodeURIComponent(artifactMatch[2]);
583
+ return serveArtifact(res, artifactMatch[1], assetPath || null);
584
+ }
585
+ if (pathname.startsWith('/api/')) {
586
+ return handleApi(req, res, url);
587
+ }
588
+ return sendJson(res, 404, { error: 'not found' });
589
+ })
590
+ .catch(error => {
591
+ if (!res.headersSent) sendJson(res, 400, { error: error.message });
592
+ else res.end();
593
+ });
594
+ });
595
+
596
+ function close() {
597
+ closed = true;
598
+ clearTimeout(idleTimer);
599
+ clearInterval(presenceSweep);
600
+ presenceSweep = null;
601
+ lastPresence.clear();
602
+ for (const key of watchers.keys()) unwatchSession(key);
603
+ for (const clients of sseClients.values()) {
604
+ for (const client of clients) client.end();
605
+ }
606
+ sseClients.clear();
607
+ wake.emit('server-close');
608
+ return new Promise((resolve, reject) => {
609
+ server.close(error => (error ? reject(error) : resolve()));
610
+ // Browser keep-alive sockets would otherwise hold close() open.
611
+ if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
612
+ });
613
+ }
614
+
615
+ function listen(port = resolvePort()) {
616
+ return new Promise((resolve, reject) => {
617
+ server.once('error', reject);
618
+ server.listen(port, host, () => {
619
+ armIdleTimer();
620
+ resolve({ port: server.address().port, host });
621
+ });
622
+ });
623
+ }
624
+
625
+ return { server, listen, close, presenceFor, sweepPresence, watchSession };
626
+ }
627
+
628
+ module.exports = {
629
+ DEFAULT_HOST,
630
+ DEFAULT_PORT,
631
+ DEFAULT_THINKING_STALE_MS,
632
+ DEFAULT_TYPING_EXPIRY_MS,
633
+ createPlanCanvasServer,
634
+ resolveIdleTimeoutMs,
635
+ resolvePort
636
+ };