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);