diff --git a/bin/iak-mcp-daemon.mjs b/bin/iak-mcp-daemon.mjs index 32dce80..4c0de40 100755 --- a/bin/iak-mcp-daemon.mjs +++ b/bin/iak-mcp-daemon.mjs @@ -17,8 +17,8 @@ import { loadConfig } from '../src/config.mjs'; import { startConfirmationsServer, - decideIntent, - getIntent, + startChatReplyPoller, + configureActionStatusPush, createIntent, makeGroupmindAnnouncer, makeCodewatchAnnouncer, @@ -83,8 +83,20 @@ if (!apiKey) { } else if (!room) { console.warn('[iak-mcp-daemon] mcp.confirmations.room missing — chat-reply poller disabled'); } else { - startChatReplyPoller({ apiKey, room, intervalMs: 5000 }); + startChatReplyPoller({ + apiKey, room, intervalMs: 5000, + log: (msg) => console.log(`[iak-mcp-daemon] ${msg}`), + }); console.log(`[iak-mcp-daemon] chat-reply poller watching room "${room}" every 5s`); + // Mirror every intent/action transition to the central action_status store + // (antfarm PR #43) so CodeWatch renders durable button state off-LAN. + const pushBase = config?.groupmind?.base_url || config?.groupmind?.baseUrl || 'https://groupmind.one/api/v1'; + if (configureActionStatusPush({ + apiKey, baseUrl: pushBase, + log: (msg) => console.log(`[iak-mcp-daemon] ${msg}`), + })) { + console.log('[iak-mcp-daemon] action-status push: enabled (durable off-LAN button state)'); + } } // Codewatch path: just announce when a message arrives at /push (not implemented @@ -118,50 +130,5 @@ if (argv.includes('--demo')) { // Keep the process alive. process.stdin.resume(); -// --- helpers -------------------------------------------------------------- - -function startChatReplyPoller({ apiKey, room, intervalMs }) { - const seen = new Set(); - let primed = false; - const poll = async () => { - try { - const url = `https://groupmind.one/api/v1/rooms/${encodeURIComponent(room)}/messages?limit=30`; - const res = await fetch(url, { headers: { 'X-API-Key': apiKey } }); - if (!res.ok) return; - const body = await res.json(); - const messages = body?.messages || []; - for (const m of messages) { - if (seen.has(m.id)) continue; - seen.add(m.id); - if (!primed) continue; // ignore historical messages on first pass - const text = (m.body || '').trim(); - const match = text.match(/^\/(approve|deny)\s+([a-f0-9]+)$/i); - if (!match) continue; - // Only the human owner may settle intents. Fleet agents share the room - // and can echo "/approve " (one did), which would execute gated - // commands without the owner. The owner posts as plain "petrus" — - // including CodeWatch button taps, which arrive with isHuman=false — - // while agents carry a handle ("@ether", "hermes"). - const sender = String(m.from || '').replace(/^@/, '').toLowerCase(); - if (sender !== 'petrus' && m.isHuman !== true) { - console.log(`[iak-mcp-daemon] ${text} from ${m.from}: sender is not the owner — ignoring`); - continue; - } - const decision = match[1].toLowerCase(); - const id = match[2]; - const intent = getIntent(id); - if (!intent) { - console.log(`[iak-mcp-daemon] /${decision} ${id} from ${m.from}: unknown intent, ignoring`); - continue; - } - const r = decideIntent(id, decision); - console.log(`[iak-mcp-daemon] /${decision} ${id} from ${m.from}: ${r.ok ? 'settled' : r.error}`); - } - primed = true; - } catch (e) { - console.warn(`[iak-mcp-daemon] poll error: ${e.message}`); - } - }; - poll(); - setInterval(poll, intervalMs); -} +// The chat-reply poller now lives in src/confirmations.mjs (startChatReplyPoller) +// so the in-process MCP confirmations server can run it too. See the import above. diff --git a/scripts/action-request.mjs b/scripts/action-request.mjs index 2123b13..fce4c80 100644 --- a/scripts/action-request.mjs +++ b/scripts/action-request.mjs @@ -23,7 +23,7 @@ // node scripts/action-request.mjs upload_play_internal --version-code 56 // node scripts/action-request.mjs install_debug_apk --version-code 56 --device // Options: --daemon (default http://127.0.0.1:8788 or $IAK_DAEMON), -// --risk low|medium|high, --ttl (default 15), +// --risk low|medium|high, --ttl (default 1440 = 24h), // --decision-room (default thinkoff-development), // --no-wait (post and exit without polling for the receipt). @@ -146,7 +146,11 @@ async function main() { const daemon = (opts.daemon || DEFAULT_DAEMON).replace(/\/$/, ''); const nonce = randomUUID(); - const ttlMin = Number(opts.ttl) > 0 ? Number(opts.ttl) : 15; + // Default TTL is 24h: a phone Approve may land hours after the button is + // posted, and a 15-min window meant a late tap silently no-opped (the action + // expired before the tap arrived). The daemon now also honors an explicit + // approval after a TTL lapse, but a long default keeps the happy path clean. + const ttlMin = Number(opts.ttl) > 0 ? Number(opts.ttl) : 1440; const expires_at = new Date(Date.now() + ttlMin * 60_000).toISOString(); const decision_room = opts['decision-room'] || DEFAULT_ROOM; @@ -194,8 +198,12 @@ async function main() { // Poll the receipt endpoint until the daemon reports a terminal status. const statusUrl = `${daemon}/actions/${nonce}`; - // Local safety cap a bit past the intent TTL so we never poll forever. - const deadline = Date.now() + (ttlMin * 60_000) + 30_000; + // Local poll window is decoupled from the action TTL: with a 24h TTL we must + // not block the caller for a day. Cap the interactive wait at 15 min; the + // button stays live on the daemon for the full TTL and executes whenever + // petrus taps, even after this script has exited. + const waitMin = Math.min(ttlMin, 15); + const deadline = Date.now() + (waitMin * 60_000) + 30_000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, POLL_MS)); let cur; @@ -210,8 +218,8 @@ async function main() { } console.error(`…${status || 'pending'}`); } - console.error('Local poll deadline reached without a terminal receipt (intent likely expired).'); - process.exit(1); + console.error(`Stopped waiting after ${waitMin}m. The approval button is still LIVE (TTL ${ttlMin}m) and will execute when petrus taps — this script just stopped polling.`); + process.exit(0); } main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/start-all.sh b/scripts/start-all.sh index 074d2a1..bbcb87d 100755 --- a/scripts/start-all.sh +++ b/scripts/start-all.sh @@ -15,4 +15,10 @@ $NODE "$CLI" rooms watch --config "$CONFIG" & $NODE "$CLI" comments watch --config "$CONFIG" & $NODE "$CLI" discord watch --config "$CONFIG" & +# Confirmations daemon: holds :8788 AND runs the chat-reply poller that routes +# room /approve /deny taps (the CodeWatch Approve/Deny buttons) to the intent +# registry + action executors. Must be iak-mcp-daemon.mjs (not iak-mcp.mjs) — +# only the daemon flavor runs the poller, without which button taps never settle. +$NODE "$IAK_DIR/bin/iak-mcp-daemon.mjs" --config "$CONFIG" > "$IAK_DIR/logs/iak-confirm-daemon.log" 2>&1 & + wait diff --git a/src/confirmations.mjs b/src/confirmations.mjs index add0f02..f14b5b3 100644 --- a/src/confirmations.mjs +++ b/src/confirmations.mjs @@ -48,6 +48,46 @@ function postReceipt(receiptsPath, entry) { } } +// --- durable action-status mirror (antfarm PR #43) -------------------------- +// Mirrors every intent/action transition to the central GroupMind action_status +// store (POST /api/v1/actions) so CodeWatch renders durable button state when +// the phone is off the LAN and can't reach this daemon's localhost /intents. +// Fire-and-forget: a push must never throw, block, or fail a decision. +let _actionStatusPush = null; +// Action vocab -> action_status lifecycle vocab. The DB enforces a monotonic +// rank (pending {} }) { + if (!apiKey) return false; + const url = String(baseUrl).replace(/\/$/, '') + '/actions'; + _actionStatusPush = (intentId, status, fields = {}) => { + if (!intentId || !status) return; + const clean = {}; + for (const [k, v] of Object.entries(fields)) if (v != null) clean[k] = v; + const body = JSON.stringify({ intent_id: intentId, status, ...clean }); + fetch(url, { + method: 'POST', + headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, + body, + }) + .then((r) => { if (!r.ok) log(`[action-status] ${intentId}->${status} HTTP ${r.status}`); }) + .catch((e) => log(`[action-status] ${intentId}->${status} failed: ${e.message}`)); + }; + log(`[action-status] push enabled -> ${url}`); + return true; +} + +function pushStatus(intentId, rawStatus, fields = {}) { + if (!_actionStatusPush) return; + try { + _actionStatusPush(intentId, ACTION_STATUS_MAP[rawStatus] || rawStatus, fields); + } catch { + // never let a status mirror disturb the gating path + } +} + export function listIntents() { return [...intents.entries()].map(([id, i]) => ({ id, @@ -114,6 +154,15 @@ export function decideIntent(id, decision, { receiptsPath } = {}) { }, { receiptsPath }); } else { action.decided_at = new Date(i.decidedAt).toISOString(); + // An explicit human Approve overrides a soft TTL lapse. If the action + // expired before the tap (e.g. petrus approved hours later), revive it + // and execute anyway — the executor re-validates (gh pr view precheck), + // so a stale-but-clean PR merges and a stale-conflicted one fails safely. + // Without this, a late tap settles the intent but silently no-ops. + if (action.status === 'expired') { + action.status = 'pending'; + process.stderr.write(`[iak-mcp] action ${action.nonce}: approved after TTL lapse — executing on explicit approval\n`); + } runApprovedAction(action, { receiptsPath }).catch((e) => { settleAction(action, { status: 'failed', @@ -126,6 +175,15 @@ export function decideIntent(id, decision, { receiptsPath } = {}) { }, { receiptsPath }); }); } + } else { + // Pure confirmation (no typed executor): the decision itself is terminal, + // so mirror it directly. Typed actions instead mirror via runApprovedAction + // (processing) and settleAction (completed/failed/denied/expired). + pushStatus(id, decision === 'approve' ? 'approved' : 'denied', { + decision, + approver: 'petrus', + decided_at: new Date(i.decidedAt).toISOString(), + }); } return { ok: true }; } @@ -159,6 +217,7 @@ export async function createIntent({ postReceipt(receiptsPath, { kind: 'intent.created', id, prompt, session, channels, createdAt: intent.createdAt, }); + pushStatus(id, 'pending', { target_summary: prompt }); // Side effects — never let an announce failure block the intent itself. try { await announce({ id, prompt, session, channels, fromHandle }); @@ -363,6 +422,16 @@ function expireAction(nonce, { receiptsPath } = {}) { function settleAction(action, patch, { receiptsPath } = {}) { Object.assign(action, patch); postReceipt(receiptsPath, { kind: 'action.receipt', ...actionReceipt(action) }); + // Mirror the terminal/transition state to the durable store. ACTION_STATUS_MAP + // translates merged->completed; denied/failed/expired/processing pass through. + pushStatus(action.intentId, action.status, { + actor: action.actor, + decision: action.status === 'denied' ? 'deny' : undefined, + receipt: action.output_summary, + error: action.status === 'failed' ? action.output_summary : undefined, + decided_at: action.decided_at, + executed_at: action.ran_at, + }); } function actionReceipt(action) { @@ -389,6 +458,7 @@ async function runApprovedAction(action, { receiptsPath } = {}) { action.status = 'running'; action.actor = 'petrus'; action.ran_at = new Date().toISOString(); + pushStatus(action.intentId, 'running', { actor: 'petrus', executed_at: action.ran_at }); switch (action.type) { case 'merge_pr': await executeMergePr(action, { receiptsPath }); @@ -850,6 +920,69 @@ export function composeAnnouncers(map) { }; } +// --- chat-reply poller ------------------------------------------------------- + +// Watch a GroupMind room for "/approve " / "/deny " quick-reply +// messages (the CodeWatch Approve/Deny buttons POST these on tap) and route +// them to decideIntent() in-process. This is what makes a phone tap actually +// settle a pending intent. It used to live only in bin/iak-mcp-daemon.mjs, so +// the in-process MCP confirmations server never routed taps — buttons looked +// dead (intent stuck "pending" after Approve). Sharing it here lets both the +// standalone daemon and the in-process server run it from one source. +// +// Logs go to stderr only (stdout is the MCP stdio protocol channel — writing +// there would corrupt it). Returns the interval handle so callers can stop it. +export function startChatReplyPoller({ apiKey, room, intervalMs = 5000, log }) { + if (!apiKey || !room) { + process.stderr.write('[iak-mcp] chat-reply poller: missing apiKey or room — disabled\n'); + return null; + } + const emit = log || ((msg) => process.stderr.write(`[iak-mcp] ${msg}\n`)); + const seen = new Set(); + let primed = false; + const poll = async () => { + try { + const url = `https://groupmind.one/api/v1/rooms/${encodeURIComponent(room)}/messages?limit=30`; + const res = await fetch(url, { headers: { 'X-API-Key': apiKey } }); + if (!res.ok) return; + const body = await res.json(); + const messages = body?.messages || []; + for (const m of messages) { + if (seen.has(m.id)) continue; + seen.add(m.id); + if (!primed) continue; // ignore historical messages on first pass + const text = (m.body || '').trim(); + const match = text.match(/^\/(approve|deny)\s+([a-f0-9]+)$/i); + if (!match) continue; + // Only the human owner may settle intents. Fleet agents share the room + // and one (hermes) auto-replied "/approve " to a confirmation card, + // which this poller happily executed — any agent could approve any + // gated command. Agent senders carry a handle ("@ether", "hermes"); + // the owner posts as plain "petrus" (CodeWatch button taps included). + const sender = String(m.from || '').replace(/^@/, '').toLowerCase(); + if (sender !== 'petrus' && m.isHuman !== true) { + emit(`${text} from ${m.from}: sender is not the owner — ignoring`); + continue; + } + const decision = match[1].toLowerCase(); + const id = match[2]; + const intent = getIntent(id); + if (!intent) { + emit(`/${decision} ${id} from ${m.from}: unknown intent, ignoring`); + continue; + } + const r = decideIntent(id, decision); + emit(`/${decision} ${id} from ${m.from}: ${r.ok ? 'settled' : r.error}`); + } + primed = true; + } catch (e) { + emit(`chat-reply poll error: ${e.message}`); + } + }; + poll(); + return setInterval(poll, intervalMs); +} + // --- testing helpers --------------------------------------------------------- // Reset all state. Used by the test suite between cases. Not exported via diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs index 753942e..208680d 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -39,6 +39,8 @@ import { waitForDecision, listIntents, startConfirmationsServer, + startChatReplyPoller, + configureActionStatusPush, makeGroupmindAnnouncer, makeCodewatchAnnouncer, composeAnnouncers, @@ -284,17 +286,40 @@ export async function runMcpServer({ configPath } = {}) { `[iak-mcp] confirmations: forwarding to live daemon at ${daemonBase}\n` ); } else if (confirmEnabled) { + const wakeScript = confirmCfg.wake_script || confirmCfg.wakeScript || + config?.poller?.wake_script || config?.poller?.nudge_command || config?.wake?.script_path || + join(__pkgDir, 'scripts', 'claude-gui-wake.sh'); confirmServer = startConfirmationsServer({ port: daemonPort, host: confirmCfg.host || '127.0.0.1', authToken: confirmCfg.auth_token || '', receiptsPath: config?.receipts?.path, announce, - wakeScript: confirmCfg.wake_script || confirmCfg.wakeScript || config?.poller?.wake_script || config?.poller?.nudge_command || config?.wake?.script_path, + wakeScript, }); process.stderr.write( `[iak-mcp] confirmations: enabled on ${daemonBase} (in-process) — channels: ${Object.keys(announcerMap).join(', ')}\n` ); + // Route room "/approve " / "/deny " taps (the CodeWatch buttons) to + // the in-process intent registry. Without this the buttons render but a tap + // never settles the intent. Only start when this process holds the intents + // (groupmind channel configured + serving in-process, not forwarding). + if (announcerMap.groupmind) { + startChatReplyPoller({ apiKey: config.poller.api_key, room: confirmCfg.room }); + process.stderr.write( + `[iak-mcp] chat-reply poller: watching room "${confirmCfg.room}" every 5s\n` + ); + // Mirror intent/action transitions to the central action_status store + // (antfarm PR #43) so CodeWatch buttons render durable state off-LAN. + const { apiKey: pushKey, baseUrl: pushBase } = configuredRoomApi(config); + if (configureActionStatusPush({ + apiKey: pushKey, + baseUrl: pushBase, + log: (m) => process.stderr.write(m + '\n'), + })) { + process.stderr.write('[iak-mcp] action-status push: enabled (durable off-LAN button state)\n'); + } + } } else { process.stderr.write( '[iak-mcp] confirmations: disabled — set mcp.confirmations.room (+ poller.api_key) and/or mcp.confirmations.codewatch_gate_url\n'