From b7ae2ce0088f45804f86f5a5a43d1b895cd04345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Carreiro?= Date: Tue, 26 May 2026 15:49:25 +0100 Subject: [PATCH] Add pending notices to command responses --- README.md | 46 +++++++++ api/notices.js | 193 +++++++++++++++++++++++++++++++++++++ api/terminal.js | 12 ++- serverModules/apiRoutes.js | 4 + 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 api/notices.js diff --git a/README.md b/README.md index 451ccc3..403cd92 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,49 @@ Or Twitter/X https://x.com/wonderwhy_er ## License The project is licensed under the MIT License. + +## Pending notices + +Other local tools can leave short notices for the ChatGPT action to see on the next terminal response. Notices are kept in memory and are returned with terminal command responses until they are acknowledged or expire. + +Create a notice: + +```http +POST /api/notices +Content-Type: application/json + +{ + "level": "warning", + "source": "local-supervisor", + "text": "Check that you are editing the live config file, not a generated copy.", + "ttlSeconds": 1800 +} +``` + +Run a terminal command as usual. If any notices are pending, the response includes them: + +```json +{ + "message": "Command executed successfully.", + "output": "...", + "notices": [ + { + "id": "notice_...", + "level": "warning", + "source": "local-supervisor", + "text": "Check that you are editing the live config file, not a generated copy.", + "createdAt": "2026-05-26T12:00:00.000Z", + "expiresAt": "2026-05-26T12:30:00.000Z", + "deliveredAt": "2026-05-26T12:00:05.000Z", + "deliveredCount": 1 + } + ] +} +``` + +Acknowledge a notice so it stops appearing: + +```http +POST /api/notices/{id}/ack +``` + diff --git a/api/notices.js b/api/notices.js new file mode 100644 index 0000000..5d41656 --- /dev/null +++ b/api/notices.js @@ -0,0 +1,193 @@ +const crypto = require('crypto'); + +const notices = []; +const DEFAULT_TTL_SECONDS = 60 * 60; +const MAX_NOTICES = 100; +const LEVELS = new Set(['info', 'warning', 'error', 'interrupt']); + +function setCors(res) { + res.setHeader('Access-Control-Allow-Origin', 'https://chat.openai.com'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, openai-conversation-id, openai-ephemeral-user-id'); + res.setHeader('Access-Control-Allow-Credentials', true); +} + +function nowIso() { + return new Date().toISOString(); +} + +function isExpired(notice, now = Date.now()) { + return notice.expiresAtMs && notice.expiresAtMs <= now; +} + +function pruneExpired() { + const now = Date.now(); + for (let i = notices.length - 1; i >= 0; i--) { + if (notices[i].ackedAt || isExpired(notices[i], now)) { + notices.splice(i, 1); + } + } + while (notices.length > MAX_NOTICES) { + notices.shift(); + } +} + +function publicNotice(notice) { + return { + id: notice.id, + level: notice.level, + source: notice.source, + text: notice.text, + createdAt: notice.createdAt, + expiresAt: notice.expiresAt, + deliveredAt: notice.deliveredAt || null, + deliveredCount: notice.deliveredCount || 0 + }; +} + +function getPendingNotices() { + pruneExpired(); + const deliveredAt = nowIso(); + return notices + .filter((notice) => !notice.ackedAt && !isExpired(notice)) + .map((notice) => { + notice.deliveredAt = deliveredAt; + notice.deliveredCount = (notice.deliveredCount || 0) + 1; + return publicNotice(notice); + }); +} + +/** + * @openapi + * /api/notices: + * post: + * summary: Create a pending notice for future command responses. + * description: Stores a small in-memory notice that will be attached to terminal command responses until it is acknowledged or expires. + * operationId: createNotice + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - text + * properties: + * text: + * type: string + * description: Notice text to show alongside future command responses. + * level: + * type: string + * enum: [info, warning, error, interrupt] + * default: info + * source: + * type: string + * default: external + * ttlSeconds: + * type: integer + * default: 3600 + * responses: + * '201': + * description: Notice queued. + * '400': + * description: Bad request. + */ +function createNoticeHandler(req, res) { + setCors(res); + if (req.method === 'OPTIONS') { + return res.status(200).end(); + } + + const body = req.body || {}; + const text = typeof body.text === 'string' ? body.text.trim() : ''; + if (!text) { + return res.status(400).json({ message: 'Notice text is required.' }); + } + + const requestedLevel = typeof body.level === 'string' ? body.level.toLowerCase() : 'info'; + const level = LEVELS.has(requestedLevel) ? requestedLevel : 'info'; + const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'external'; + const ttlSeconds = Number.isFinite(Number(body.ttlSeconds)) && Number(body.ttlSeconds) > 0 + ? Math.min(Number(body.ttlSeconds), 24 * 60 * 60) + : DEFAULT_TTL_SECONDS; + + const createdAtMs = Date.now(); + const expiresAtMs = createdAtMs + ttlSeconds * 1000; + const notice = { + id: `notice_${createdAtMs}_${crypto.randomBytes(4).toString('hex')}`, + level, + source, + text, + createdAt: new Date(createdAtMs).toISOString(), + expiresAt: new Date(expiresAtMs).toISOString(), + expiresAtMs, + deliveredAt: null, + deliveredCount: 0, + ackedAt: null + }; + + notices.push(notice); + pruneExpired(); + return res.status(201).json({ message: 'Notice queued.', notice: publicNotice(notice) }); +} + +/** + * @openapi + * /api/notices/pending: + * get: + * summary: List pending notices. + * description: Returns unacknowledged, non-expired notices and marks them as delivered. + * operationId: listPendingNotices + * responses: + * '200': + * description: Pending notices. + */ +function pendingNoticesHandler(req, res) { + setCors(res); + if (req.method === 'OPTIONS') { + return res.status(200).end(); + } + return res.status(200).json({ notices: getPendingNotices() }); +} + +/** + * @openapi + * /api/notices/{id}/ack: + * post: + * summary: Acknowledge a pending notice. + * description: Removes a notice from future command responses. + * operationId: acknowledgeNotice + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Notice acknowledged. + * '404': + * description: Notice not found. + */ +function ackNoticeHandler(req, res) { + setCors(res); + if (req.method === 'OPTIONS') { + return res.status(200).end(); + } + + const notice = notices.find((item) => item.id === req.params.id && !item.ackedAt); + if (!notice) { + return res.status(404).json({ message: 'Notice not found.' }); + } + + notice.ackedAt = nowIso(); + pruneExpired(); + return res.status(200).json({ message: 'Notice acknowledged.', id: req.params.id }); +} + +module.exports = { + createNoticeHandler, + pendingNoticesHandler, + ackNoticeHandler, + getPendingNotices +}; diff --git a/api/terminal.js b/api/terminal.js index d627489..ac49e13 100644 --- a/api/terminal.js +++ b/api/terminal.js @@ -1,4 +1,5 @@ const { spawn } = require('child_process'); +const { getPendingNotices } = require('./notices'); // Create a persistent shell let shell; @@ -59,6 +60,11 @@ let output = ""; * output: * type: string * description: The output of the executed command. + * notices: + * type: array + * description: Pending notices attached to the command response. + * items: + * type: object * '400': * description: Bad request (e.g., missing command parameter). * '500': @@ -110,12 +116,14 @@ function terminalHandler(req, res) { console.log(`Command executed successfully. Output: ${output}`); shell.stdout.removeListener('data', getOutput); shell.stderr.removeListener('data', getError); + const notices = getPendingNotices(); if (output.length < 4097) { - return res.status(200).json({message: 'Command executed successfully.', output}); + return res.status(200).json({message: 'Command executed successfully.', output, notices}); } else { return res.status(200).json({ message: 'Command executed successfully. But size is too big, returning 3900 first symbols', - output: output.substr(0, 3900) + output: output.substr(0, 3900), + notices }); } } diff --git a/serverModules/apiRoutes.js b/serverModules/apiRoutes.js index 928c336..0aad5c2 100644 --- a/serverModules/apiRoutes.js +++ b/serverModules/apiRoutes.js @@ -1,4 +1,5 @@ const {terminalHandler, interruptHandler} = require('../api/terminal'); +const {createNoticeHandler, pendingNoticesHandler, ackNoticeHandler} = require('../api/notices'); //const createAppHandlerWithUrl = require('../api/firebase'); // Modify import to pass getURL function const exitApplicationHandler = require('../api/exitApplicationHandler'); @@ -29,6 +30,9 @@ module.exports = { .get(createAppHandler); // Add support for GET requests*/ app.get('/api/server-url', require('../api/getServerUrlHandler')(getURL)); app.get('/api/logs', require('../api/getLogsHandler')); + app.post('/api/notices', createNoticeHandler); + app.get('/api/notices/pending', pendingNoticesHandler); + app.post('/api/notices/:id/ack', ackNoticeHandler); app.post('/api/restart', exitApplicationHandler(close)); app.post("/api/interrupt", interruptHandler); app.post('/api/read-or-edit-file', readEditTextFileHandler);