From e409465a1993bcefb6d44da9df0a8b58e8428f3c Mon Sep 17 00:00:00 2001 From: dundas Date: Sun, 1 Mar 2026 08:59:03 -0600 Subject: [PATCH 1/3] feat(cli): add round-tables subcommand group with watch daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `admp round-tables` command group with 6 subcommands: create POST /api/round-tables — create session with topic, goal, participants list GET /api/round-tables — list mine, filterable by status get GET /api/round-tables/:id — full session + thread dump speak POST /api/round-tables/:id/speak — contribute a message resolve POST /api/round-tables/:id/resolve — close with outcome (facilitator) watch Daemon loop — polls GET /:id every N ms, emits new thread entries as they arrive, fires --on-speak hook (entry JSON piped to stdin), auto-exits on resolved (0) / expired (1), handles SIGTERM/SIGINT cleanly watch design mirrors the Teleportation daemon: - setInterval-style sleep loop (not setInterval, to avoid drift on slow polls) - Cursor = lastLength (thread array index, append-only, no server cursor needed) - Transient errors: warn + continue (network blip should not kill a long-running watch) - --on-speak: spawn /bin/sh -c per entry, fire-and-forget, non-blocking - --json mode: all events as { event, ... } lines to stdout, warnings to stderr - Initial fetch before loop sets cursor so history is not replayed on first poll Co-Authored-By: Claude Sonnet 4.6 --- cli/src/commands/round-tables.ts | 360 +++++++++++++++++++++++++++++++ cli/src/index.ts | 3 +- 2 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 cli/src/commands/round-tables.ts diff --git a/cli/src/commands/round-tables.ts b/cli/src/commands/round-tables.ts new file mode 100644 index 0000000..35469bf --- /dev/null +++ b/cli/src/commands/round-tables.ts @@ -0,0 +1,360 @@ +import { Command } from 'commander'; +import { spawn } from 'child_process'; +import { AdmpClient, AdmpError } from '../client.js'; +import { requireConfig } from '../config.js'; +import { success, error, warn, isJsonMode } from '../output.js'; + +// ---- Types ------------------------------------------------------------------ + +interface RoundTableEntry { + id: string; + from: string; + message: string; + timestamp: string; +} + +interface RoundTable { + id: string; + topic: string; + goal: string; + facilitator: string; + participants: string[]; + group_id: string; + status: 'open' | 'resolved' | 'expired'; + thread: RoundTableEntry[]; + outcome: string | null; + created_at: string; + expires_at: string; + resolved_at?: string; + decision?: string; +} + +interface RoundTableListResponse { + round_tables: RoundTable[]; + count: number; +} + +interface CreateRoundTableResponse extends RoundTable { + excluded_participants?: string[]; +} + +// ---- Helpers ---------------------------------------------------------------- + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function validateRtId(id: string): void { + if (!/^[\w\-]+$/.test(id)) { + error('Round table ID must contain only alphanumeric characters, hyphens, and underscores', 'INVALID_ARGUMENT'); + process.exit(1); + } +} + +function printEntry(entry: RoundTableEntry): void { + if (isJsonMode()) { + console.log(JSON.stringify({ event: 'entry', entry })); + return; + } + const ts = new Date(entry.timestamp).toLocaleTimeString(); + console.log(`[${ts}] \x1b[36m${entry.from}\x1b[0m`); + console.log(` ${entry.message}`); + console.log(''); +} + +function printClosure(rt: RoundTable): void { + if (isJsonMode()) { + console.log(JSON.stringify({ event: 'closed', status: rt.status, outcome: rt.outcome, decision: rt.decision })); + return; + } + console.log(''); + console.log(rt.status === 'resolved' ? '\x1b[32m✓ Round table resolved\x1b[0m' : '\x1b[33m⚠ Round table expired\x1b[0m'); + if (rt.outcome) console.log(` Outcome: ${rt.outcome}`); + if (rt.decision) console.log(` Decision: ${rt.decision}`); + console.log(''); +} + +function fireHook(cmd: string, entry: RoundTableEntry): void { + const child = spawn('/bin/sh', ['-c', cmd], { + stdio: ['pipe', 'inherit', 'inherit'], + }); + child.stdin.write(JSON.stringify(entry) + '\n'); + child.stdin.end(); + child.on('error', (err) => warn(`--on-speak hook error: ${err.message}`)); + child.on('exit', (code) => { + if (code !== 0 && code !== null) warn(`--on-speak hook exited with code ${code}`); + }); +} + +// ---- Command registration --------------------------------------------------- + +export function register(program: Command): void { + const cmd = program + .command('round-tables') + .description('Manage Round Table deliberation sessions'); + + // ---- create ---------------------------------------------------------------- + + cmd + .command('create') + .description('Create a new Round Table session') + .requiredOption('--topic ', 'Session topic (max 500 chars)') + .requiredOption('--goal ', 'Desired outcome (max 500 chars)') + .requiredOption('--participants ', 'Comma-separated participant agent IDs') + .option('--timeout-minutes ', 'Auto-expire after N minutes (integer, 1–10080, default 30)') + .addHelpText('after', ` +Examples: + admp round-tables create --topic "API design" --goal "Agree on schema" --participants agent-a,agent-b + admp round-tables create --topic "Incident review" --goal "Root cause" --participants agent-a --timeout-minutes 60`) + .action(async (opts: { topic: string; goal: string; participants: string; timeoutMinutes?: string }) => { + const participants = opts.participants.split(',').map(s => s.trim()).filter(Boolean); + if (participants.length === 0) { + error('--participants must contain at least one agent ID', 'INVALID_ARGUMENT'); + process.exit(1); + } + + let timeoutMinutes: number | undefined; + if (opts.timeoutMinutes !== undefined) { + timeoutMinutes = parseInt(opts.timeoutMinutes, 10); + if (isNaN(timeoutMinutes) || !Number.isInteger(timeoutMinutes) || timeoutMinutes < 1 || timeoutMinutes > 10080) { + error('--timeout-minutes must be an integer between 1 and 10080', 'INVALID_ARGUMENT'); + process.exit(1); + } + } + + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const body: Record = { topic: opts.topic, goal: opts.goal, participants }; + if (timeoutMinutes !== undefined) body.timeout_minutes = timeoutMinutes; + + const res = await client.request('POST', '/api/round-tables', body, 'signature'); + + if (!isJsonMode() && res.excluded_participants && res.excluded_participants.length > 0) { + warn(`Some participants could not be enrolled: ${res.excluded_participants.join(', ')}`); + } + success('Round table created', res); + }); + + // ---- list ------------------------------------------------------------------ + + cmd + .command('list') + .description('List Round Tables you are a facilitator or participant of') + .option('--status ', 'Filter by status: open | resolved | expired') + .addHelpText('after', ` +Examples: + admp round-tables list + admp round-tables list --status open`) + .action(async (opts: { status?: string }) => { + const VALID_STATUSES = ['open', 'resolved', 'expired']; + if (opts.status && !VALID_STATUSES.includes(opts.status)) { + error(`--status must be one of: ${VALID_STATUSES.join(', ')}`, 'INVALID_ARGUMENT'); + process.exit(1); + } + + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const params = opts.status ? `?status=${encodeURIComponent(opts.status)}` : ''; + const res = await client.request('GET', `/api/round-tables${params}`, undefined, 'signature'); + + const tables = res?.round_tables ?? []; + if (isJsonMode()) { console.log(JSON.stringify(tables, null, 2)); return; } + + if (tables.length === 0) { console.log('No round tables.'); return; } + + const idWidth = Math.max('ID'.length, ...tables.map(t => t.id.length)); + console.log(`\n${'ID'.padEnd(idWidth)} ${'TOPIC'.padEnd(42)} ${'STATUS'.padEnd(10)} ${'PARTS'.padEnd(5)} EXPIRES`); + console.log('─'.repeat(idWidth + 1 + 42 + 1 + 10 + 1 + 5 + 1 + 24)); + for (const t of tables) { + const topic = t.topic.length > 40 ? t.topic.slice(0, 39) + '…' : t.topic; + console.log(`${t.id.padEnd(idWidth)} ${topic.padEnd(42)} ${t.status.padEnd(10)} ${String(t.participants.length).padEnd(5)} ${t.expires_at}`); + } + console.log(''); + }); + + // ---- get ------------------------------------------------------------------- + + cmd + .command('get ') + .description('Get a Round Table session and its full thread') + .addHelpText('after', '\nExample:\n admp round-tables get rt_abc123def456') + .action(async (id: string) => { + validateRtId(id); + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const rt = await client.request('GET', `/api/round-tables/${id}`, undefined, 'signature'); + + if (isJsonMode()) { console.log(JSON.stringify(rt, null, 2)); return; } + + console.log(''); + console.log(`\x1b[1m${rt.topic}\x1b[0m \x1b[2m${rt.id}\x1b[0m`); + console.log(` Goal: ${rt.goal}`); + console.log(` Facilitator: ${rt.facilitator}`); + console.log(` Participants: ${rt.participants.join(', ') || '(none)'}`); + console.log(` Status: ${rt.status}`); + console.log(` Expires: ${rt.expires_at}`); + if (rt.outcome) console.log(` Outcome: ${rt.outcome}`); + if (rt.decision) console.log(` Decision: ${rt.decision}`); + console.log(''); + + if (rt.thread.length === 0) { + console.log(' (no messages yet)'); + } else { + console.log('\x1b[2m─'.repeat(60) + '\x1b[0m'); + for (const entry of rt.thread) { + printEntry(entry); + } + } + }); + + // ---- speak ----------------------------------------------------------------- + + cmd + .command('speak ') + .description('Add a message to the Round Table thread') + .requiredOption('--message ', 'Message to contribute (max 10000 chars)') + .addHelpText('after', '\nExample:\n admp round-tables speak rt_abc123def456 --message "I propose we use event sourcing."') + .action(async (id: string, opts: { message: string }) => { + validateRtId(id); + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const res = await client.request<{ thread_entry_id: string; thread_length: number }>( + 'POST', + `/api/round-tables/${id}/speak`, + { message: opts.message }, + 'signature' + ); + + success('Message posted', res); + }); + + // ---- resolve --------------------------------------------------------------- + + cmd + .command('resolve ') + .description('Close a Round Table with an outcome (facilitator only)') + .requiredOption('--outcome ', 'Summary of what was decided (max 2000 chars)') + .option('--decision ', 'Structured decision string (defaults to "approved")') + .addHelpText('after', ` +Examples: + admp round-tables resolve rt_abc123def456 --outcome "We will use event sourcing." + admp round-tables resolve rt_abc123def456 --outcome "Rejected." --decision rejected`) + .action(async (id: string, opts: { outcome: string; decision?: string }) => { + validateRtId(id); + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + const body: Record = { outcome: opts.outcome }; + if (opts.decision !== undefined) body.decision = opts.decision; + + const res = await client.request('POST', `/api/round-tables/${id}/resolve`, body, 'signature'); + success('Round table resolved', res); + }); + + // ---- watch (daemon loop) --------------------------------------------------- + + cmd + .command('watch ') + .description('Watch a Round Table for new thread entries (daemon loop)') + .option('--interval ', 'Poll interval in milliseconds (min 500, default 3000)', '3000') + .option('--on-speak ', 'Shell command to run on each new entry (entry JSON piped to stdin)') + .option('--no-exit-on-close', 'Do not auto-exit when session resolves or expires') + .addHelpText('after', ` +Examples: + admp round-tables watch rt_abc123def456 + admp round-tables watch rt_abc123def456 --interval 5000 + admp round-tables watch rt_abc123def456 --on-speak 'jq .message' + admp round-tables watch rt_abc123def456 --json --on-speak 'my-agent-handler' + admp round-tables watch rt_abc123def456 --no-exit-on-close`) + .action(async (id: string, opts: { interval: string; onSpeak?: string; exitOnClose: boolean }) => { + validateRtId(id); + + const intervalMs = (() => { + const n = parseInt(opts.interval, 10); + if (isNaN(n) || n < 500) { + error('--interval must be at least 500 ms', 'INVALID_ARGUMENT'); + process.exit(1); + } + return n; + })(); + + const config = requireConfig(['agent_id', 'secret_key', 'base_url']); + const client = new AdmpClient(config); + + // Initial fetch — print banner and initialise cursor so we don't replay history + const rt = await client.request('GET', `/api/round-tables/${id}`, undefined, 'signature'); + + let lastLength = rt.thread.length; + + if (isJsonMode()) { + console.log(JSON.stringify({ event: 'watch_start', round_table: rt })); + } else { + console.log(''); + console.log(`\x1b[1mWatching Round Table\x1b[0m \x1b[2m${rt.id}\x1b[0m`); + console.log(` Topic: ${rt.topic}`); + console.log(` Goal: ${rt.goal}`); + console.log(` Expires: ${rt.expires_at}`); + console.log(` Participants: ${rt.participants.length} (${rt.participants.join(', ')})`); + console.log(` Status: ${rt.status}`); + if (lastLength > 0) console.log(` Thread: ${lastLength} existing entries (skipped)`); + console.log(`Polling every ${intervalMs}ms — Ctrl+C to exit`); + console.log('\x1b[2m' + '─'.repeat(60) + '\x1b[0m'); + console.log(''); + } + + if (opts.exitOnClose && rt.status !== 'open') { + printClosure(rt); + process.exit(rt.status === 'resolved' ? 0 : 1); + } + + let running = true; + + // Signal handlers — capture lastLength in closure for the summary + const handleSignal = (signal: string) => { + running = false; + if (isJsonMode()) { + console.log(JSON.stringify({ event: 'interrupted', signal, entries_seen: lastLength })); + } else { + console.log(`\nReceived ${signal} — stopping watch.`); + console.log(` Entries seen: ${lastLength}`); + } + process.exit(0); + }; + process.once('SIGTERM', () => handleSignal('SIGTERM')); + process.once('SIGINT', () => handleSignal('SIGINT')); + + // Poll loop + while (running) { + await sleep(intervalMs); + if (!running) break; + + let current: RoundTable; + try { + current = await client.request('GET', `/api/round-tables/${id}`, undefined, 'signature'); + } catch (err) { + const msg = err instanceof AdmpError ? err.message : String(err); + warn(`Poll error (will retry): ${msg}`); + continue; + } + + // Emit new entries + const newEntries = current.thread.slice(lastLength); + for (const entry of newEntries) { + printEntry(entry); + if (opts.onSpeak) fireHook(opts.onSpeak, entry); + } + lastLength = current.thread.length; + + // Auto-exit when session closes + if (opts.exitOnClose && current.status !== 'open') { + running = false; + printClosure(current); + process.exit(current.status === 'resolved' ? 0 : 1); + } + } + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 43a328f..68bee8a 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -20,6 +20,7 @@ import * as rotateKeyCmd from './commands/rotate-key.js'; import * as webhookCmd from './commands/webhook.js'; import * as groupsCmd from './commands/groups.js'; import * as outboxCmd from './commands/outbox.js'; +import * as roundTablesCmd from './commands/round-tables.js'; export const program = new Command(); @@ -33,7 +34,7 @@ const commandModules = [ initCmd, configCmd, registerCmd, agentCmd, sendCmd, pullCmd, ackCmd, nackCmd, replyCmd, statusCmd, inboxCmd, heartbeatCmd, rotateKeyCmd, - webhookCmd, groupsCmd, outboxCmd, + webhookCmd, groupsCmd, outboxCmd, roundTablesCmd, ]; for (const mod of commandModules) { From 0ce22abb443a33a20c2567cf0bbd25452cae0e55 Mon Sep 17 00:00:00 2001 From: dundas Date: Sun, 1 Mar 2026 09:44:46 -0600 Subject: [PATCH 2/3] fix(round-tables): auth middleware, watch fatal-error handling, host header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs found and fixed during adversarial review + local smoke testing: 1. src/routes/round-tables.js — Round Table routes used authenticateAgent which requires an :agentId URL param or X-Agent-ID header. Since /api/round-tables paths have no :agentId segment, all requests returned AGENT_ID_REQUIRED. Fixed by switching to authenticateHttpSignature, which extracts identity from the Signature headers keyId — the correct middleware for agent-auth routes without an agent ID in the URL. 2. cli/src/client.ts — url.hostname omits the port on non-standard URLs (e.g. localhost:8099 becomes localhost). The server reads the actual Host header (including port) when verifying Ed25519 signatures, causing a mismatch and SIGNATURE_INVALID for any non-standard-port deployment. Fixed: url.host includes port for non-standard ports, omits it for 80/443 (matching HTTP spec). 3. cli/src/commands/round-tables.ts — watch poll loop caught all errors and retried, including fatal 4xx responses (403 removed from session, 404 session deleted). The daemon would spin indefinitely printing warnings. Fixed: AdmpError with status < 500 is treated as fatal, log once and exit(1). Co-Authored-By: Claude Sonnet 4.6 --- cli/src/client.ts | 2 +- cli/src/commands/round-tables.ts | 5 +++++ src/routes/round-tables.js | 12 ++++++------ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cli/src/client.ts b/cli/src/client.ts index 9230d61..cbd2de4 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -40,7 +40,7 @@ export class AdmpClient { timeoutOverrideMs?: number ): Promise { const url = new URL(path, this.config.base_url); - const host = url.hostname; + const host = url.host; // includes port for non-standard ports (e.g. localhost:8099) // Only set Content-Type when there is a body; avoids spurious header on GETs. const headers: Record = {}; diff --git a/cli/src/commands/round-tables.ts b/cli/src/commands/round-tables.ts index 35469bf..8263e31 100644 --- a/cli/src/commands/round-tables.ts +++ b/cli/src/commands/round-tables.ts @@ -336,6 +336,11 @@ Examples: try { current = await client.request('GET', `/api/round-tables/${id}`, undefined, 'signature'); } catch (err) { + if (err instanceof AdmpError && err.status < 500) { + // Fatal client error (403 removed from session, 404 session deleted, etc.) — stop watching + error(`Watch terminated: ${err.message}`, err.code); + process.exit(1); + } const msg = err instanceof AdmpError ? err.message : String(err); warn(`Poll error (will retry): ${msg}`); continue; diff --git a/src/routes/round-tables.js b/src/routes/round-tables.js index 41b5e93..3736065 100644 --- a/src/routes/round-tables.js +++ b/src/routes/round-tables.js @@ -5,7 +5,7 @@ import express from 'express'; import { roundTableService } from '../services/round-table.service.js'; -import { authenticateAgent } from '../middleware/auth.js'; +import { authenticateHttpSignature } from '../middleware/auth.js'; const router = express.Router(); @@ -17,7 +17,7 @@ function getErrorStatusCode(error) { * POST /api/round-tables * Create a new Round Table session */ -router.post('/', authenticateAgent, async (req, res) => { +router.post('/', authenticateHttpSignature, async (req, res) => { try { const { topic, goal, participants, timeout_minutes } = req.body; @@ -65,7 +65,7 @@ router.post('/', authenticateAgent, async (req, res) => { * GET /api/round-tables * List Round Tables, optionally filtered by status and/or participant */ -router.get('/', authenticateAgent, async (req, res) => { +router.get('/', authenticateHttpSignature, async (req, res) => { try { const { status } = req.query; const tables = await roundTableService.list({ @@ -82,7 +82,7 @@ router.get('/', authenticateAgent, async (req, res) => { * GET /api/round-tables/:id * Get a Round Table session (facilitator or participant only) */ -router.get('/:id', authenticateAgent, async (req, res) => { +router.get('/:id', authenticateHttpSignature, async (req, res) => { try { const rt = await roundTableService.get(req.params.id, req.agent.agent_id); res.json(rt); @@ -95,7 +95,7 @@ router.get('/:id', authenticateAgent, async (req, res) => { * POST /api/round-tables/:id/speak * Contribute a message to the thread (participants only) */ -router.post('/:id/speak', authenticateAgent, async (req, res) => { +router.post('/:id/speak', authenticateHttpSignature, async (req, res) => { try { const { message } = req.body; @@ -121,7 +121,7 @@ router.post('/:id/speak', authenticateAgent, async (req, res) => { * POST /api/round-tables/:id/resolve * Resolve a Round Table (facilitator only) */ -router.post('/:id/resolve', authenticateAgent, async (req, res) => { +router.post('/:id/resolve', authenticateHttpSignature, async (req, res) => { try { const { outcome, decision } = req.body; From a3cbb305fcf211d49a6489519d5c5a2b4ce886d0 Mon Sep 17 00:00:00 2001 From: dundas Date: Sun, 1 Mar 2026 13:09:00 -0600 Subject: [PATCH 3/3] fix(review): address code review feedback on round-tables watch Fixes four issues raised in PR review: 1. entries_seen off-by-initial-thread-length (bug) lastLength was initialized to rt.thread.length (pre-existing entries explicitly skipped at startup). Signal handler now subtracts initialLength so entries_seen reflects only entries observed during this watch session. 2. child.stdin null-check + EPIPE guard (bug / P1) TypeScript types child.stdin as Writable|null. Added null guard before writing. Added error listener on child.stdin to swallow EPIPE when a short-lived hook exits before reading stdin, preventing an uncaught exception from crashing the watch loop. 3. Consecutive poll-error ceiling (important) Watch loop previously retried transient errors indefinitely. After 10 consecutive failures the daemon now exits with POLL_FAILED to give an actionable signal rather than silently stalling. 4. Thread-cap warning (important) Server caps threads at 200 entries. A warning is now emitted when thread.length reaches 200 so users understand why activity appears to have stopped. Also removed redundant running=false before process.exit() in the auto-exit path. Co-Authored-By: Claude Sonnet 4.6 --- cli/src/commands/round-tables.ts | 33 ++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/round-tables.ts b/cli/src/commands/round-tables.ts index 8263e31..8fe3170 100644 --- a/cli/src/commands/round-tables.ts +++ b/cli/src/commands/round-tables.ts @@ -78,8 +78,11 @@ function fireHook(cmd: string, entry: RoundTableEntry): void { const child = spawn('/bin/sh', ['-c', cmd], { stdio: ['pipe', 'inherit', 'inherit'], }); - child.stdin.write(JSON.stringify(entry) + '\n'); - child.stdin.end(); + if (child.stdin) { + child.stdin.on('error', () => { /* ignore EPIPE — hook exited before reading stdin */ }); + child.stdin.write(JSON.stringify(entry) + '\n'); + child.stdin.end(); + } child.on('error', (err) => warn(`--on-speak hook error: ${err.message}`)); child.on('exit', (code) => { if (code !== 0 && code !== null) warn(`--on-speak hook exited with code ${code}`); @@ -288,7 +291,8 @@ Examples: // Initial fetch — print banner and initialise cursor so we don't replay history const rt = await client.request('GET', `/api/round-tables/${id}`, undefined, 'signature'); - let lastLength = rt.thread.length; + const initialLength = rt.thread.length; // entries that existed before this watch started + let lastLength = initialLength; if (isJsonMode()) { console.log(JSON.stringify({ event: 'watch_start', round_table: rt })); @@ -313,14 +317,14 @@ Examples: let running = true; - // Signal handlers — capture lastLength in closure for the summary + // Signal handlers — report only entries seen during this watch, not pre-existing ones const handleSignal = (signal: string) => { - running = false; + const entriesSeen = lastLength - initialLength; if (isJsonMode()) { - console.log(JSON.stringify({ event: 'interrupted', signal, entries_seen: lastLength })); + console.log(JSON.stringify({ event: 'interrupted', signal, entries_seen: entriesSeen })); } else { console.log(`\nReceived ${signal} — stopping watch.`); - console.log(` Entries seen: ${lastLength}`); + console.log(` Entries seen: ${entriesSeen}`); } process.exit(0); }; @@ -328,6 +332,9 @@ Examples: process.once('SIGINT', () => handleSignal('SIGINT')); // Poll loop + const MAX_CONSECUTIVE_ERRORS = 10; + let consecutiveErrors = 0; + while (running) { await sleep(intervalMs); if (!running) break; @@ -335,14 +342,20 @@ Examples: let current: RoundTable; try { current = await client.request('GET', `/api/round-tables/${id}`, undefined, 'signature'); + consecutiveErrors = 0; // reset on success } catch (err) { if (err instanceof AdmpError && err.status < 500) { // Fatal client error (403 removed from session, 404 session deleted, etc.) — stop watching error(`Watch terminated: ${err.message}`, err.code); process.exit(1); } + consecutiveErrors++; const msg = err instanceof AdmpError ? err.message : String(err); warn(`Poll error (will retry): ${msg}`); + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + error(`Watch terminated: ${MAX_CONSECUTIVE_ERRORS} consecutive poll errors`, 'POLL_FAILED'); + process.exit(1); + } continue; } @@ -354,9 +367,13 @@ Examples: } lastLength = current.thread.length; + // Warn if thread is at server-side cap — no new entries will appear until session closes + if (lastLength >= 200 && newEntries.length === 0 && current.status === 'open') { + warn('Thread is at the 200-entry cap — no new messages can be added until the session resolves or expires'); + } + // Auto-exit when session closes if (opts.exitOnClose && current.status !== 'open') { - running = false; printClosure(current); process.exit(current.status === 'resolved' ? 0 : 1); }