Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.
Merged
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
27 changes: 27 additions & 0 deletions src/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
* GET /sse — Server-Sent Events: `agent-state`, `toggle-voice`,
* `toggle-mute` — consumed by the page.
* GET /sse-status — JSON snapshot { muted, voiceConnected, state, label, clients }.
* GET /presenter — JSON { active, expiresAt } from the presenter-mode
* sentinel (state/presenter-mode.sentinel). Drives
* the UI presenter badge; same file the bridges poll.
* GET /voice-mode — JSON { mode: 'active' | 'meeting' }.
* GET /mute-state — Read/write the browser + tool agent-state tracks.
* Used by main.swift, voice-agent.ts tool hooks, and the page.
Expand Down Expand Up @@ -380,6 +383,30 @@ export function startWebServer(opts: WebServerOptions): import('node:http').Serv
return;
}

// Presenter-mode sentinel (state/presenter-mode.sentinel, written by
// scripts/presenter-mode.sh — same file the Discord/Slack/Telegram
// bridges poll to suppress notifications during a talk). The body is
// an ISO-8601 expiry; treat any unparseable or past timestamp as
// inactive (matches the bridges' is_active checks). The React badge
// polls this for the composite 3-mode badge.
if (url.pathname === '/presenter') {
let active = false;
let expiresAt: string | null = null;
try {
const raw = readFileSync(statePath('state/presenter-mode.sentinel'), 'utf-8').trim();
if (raw) {
const expiry = Date.parse(raw);
if (!Number.isNaN(expiry) && expiry > Date.now()) {
active = true;
expiresAt = raw;
}
}
} catch { /* sentinel missing → inactive */ }
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ active, expiresAt }));
return;
}

// Voice-agent mode sentinel (state/voice-mode.txt written by voice-agent
// on switch_mode / zoom-auto-flip). Returns "active" or "meeting".
// Falls back to "active" if the file is missing. Combined with the
Expand Down
53 changes: 0 additions & 53 deletions tests/tasks-api.test.ts

This file was deleted.

87 changes: 87 additions & 0 deletions tests/web-server-presenter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, after } from 'node:test';
import assert from 'node:assert/strict';
import type { Server } from 'node:http';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { setupTempWorkspace } from './_helpers/temp-workspace.js';
import { startWebServer } from '../src/web-server.js';

// Phase 5.2: the /presenter endpoint exposes the presenter-mode sentinel
// (state/presenter-mode.sentinel — the same file scripts/presenter-mode.sh
// writes and the Discord/Slack/Telegram bridges poll) to the React UI so it
// can draw the presenter badge.
//
// Contract:
// - sentinel with a FUTURE ISO expiry → { active: true, expiresAt: <iso> }
// - sentinel missing → { active: false, expiresAt: null }
// - sentinel with a PAST expiry → { active: false, expiresAt: null }
// - sentinel with garbage → { active: false, expiresAt: null }

const PORT = 18093;
const WS_PORT = 19903;

const { workspace: TEMP_WORKSPACE, cleanup: cleanupTempWorkspace } =
setupTempWorkspace('web-server-presenter');
const STATE_DIR = join(TEMP_WORKSPACE, 'state');
mkdirSync(STATE_DIR, { recursive: true });
const SENTINEL = join(STATE_DIR, 'presenter-mode.sentinel');

let server: Server;

async function fetchPresenter(): Promise<{ active: boolean; expiresAt: string | null }> {
const res = await fetch(`http://127.0.0.1:${PORT}/presenter`);
assert.equal(res.status, 200);
return res.json() as Promise<{ active: boolean; expiresAt: string | null }>;
}

async function startServer(): Promise<Server> {
const s = startWebServer({ port: PORT, host: '127.0.0.1', wsPort: WS_PORT });
await new Promise<void>((resolve, reject) => {
if (s.listening) return resolve();
s.once('listening', () => resolve());
s.once('error', reject);
});
return s;
}

describe('/presenter — presenter-mode sentinel endpoint (Phase 5.2)', () => {
after(async () => {
await new Promise<void>((resolve) => {
if (!server || !server.listening) return resolve();
server.close(() => resolve());
});
try { rmSync(SENTINEL, { force: true }); } catch { /* idempotent */ }
cleanupTempWorkspace();
});

it('reports inactive when the sentinel is missing', async () => {
try { rmSync(SENTINEL, { force: true }); } catch { /* none */ }
server = await startServer();
const body = await fetchPresenter();
assert.equal(body.active, false);
assert.equal(body.expiresAt, null);
});

it('reports active with a future ISO expiry', async () => {
const future = new Date(Date.now() + 30 * 60 * 1000).toISOString();
writeFileSync(SENTINEL, future);
const body = await fetchPresenter();
assert.equal(body.active, true);
assert.equal(body.expiresAt, future);
});

it('reports inactive for a past expiry', async () => {
const past = new Date(Date.now() - 60 * 1000).toISOString();
writeFileSync(SENTINEL, past);
const body = await fetchPresenter();
assert.equal(body.active, false);
assert.equal(body.expiresAt, null);
});

it('reports inactive for an unparseable sentinel body', async () => {
writeFileSync(SENTINEL, 'not-a-timestamp');
const body = await fetchPresenter();
assert.equal(body.active, false);
assert.equal(body.expiresAt, null);
});
});