Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

193 changes: 193 additions & 0 deletions api/notices.js
Original file line number Diff line number Diff line change
@@ -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
};
12 changes: 10 additions & 2 deletions api/terminal.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { spawn } = require('child_process');
const { getPendingNotices } = require('./notices');

// Create a persistent shell
let shell;
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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
});
}
}
Expand Down
4 changes: 4 additions & 0 deletions serverModules/apiRoutes.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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);
Expand Down