Skip to content
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
67 changes: 17 additions & 50 deletions bin/iak-mcp-daemon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import { loadConfig } from '../src/config.mjs';
import {
startConfirmationsServer,
decideIntent,
getIntent,
startChatReplyPoller,
configureActionStatusPush,
createIntent,
makeGroupmindAnnouncer,
makeCodewatchAnnouncer,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <id>" (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.
20 changes: 14 additions & 6 deletions scripts/action-request.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <serial>
// Options: --daemon <url> (default http://127.0.0.1:8788 or $IAK_DAEMON),
// --risk low|medium|high, --ttl <minutes> (default 15),
// --risk low|medium|high, --ttl <minutes> (default 1440 = 24h),
// --decision-room <room> (default thinkoff-development),
// --no-wait (post and exit without polling for the receipt).

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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); });
6 changes: 6 additions & 0 deletions scripts/start-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 &

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Create the log directory before redirecting

This new startup command redirects into $IAK_DIR/logs/iak-confirm-daemon.log, but I could not find any repo setup or this script creating $IAK_DIR/logs. In bash, redirection to a file in a missing directory fails before node is exec'd, so on a fresh checkout or machine without that directory the newly added confirmations daemon will not start and button approvals will never be routed.

Useful? React with 👍 / 👎.


wait
133 changes: 133 additions & 0 deletions src/confirmations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<processing<approved<denied<terminal), so we never push a state
// that would move a row backwards.
const ACTION_STATUS_MAP = { merged: 'completed', running: 'processing' };

export function configureActionStatusPush({ apiKey, baseUrl = 'https://groupmind.one/api/v1', log = () => {} }) {
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,
Expand Down Expand Up @@ -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',
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
Comment on lines +427 to +430

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid mirroring revivable expirations as terminal

When an action reaches its TTL, expireAction() now reaches this new pushStatus() path and mirrors expired to the durable action_status row. The same commit allows a later human Approve to reset the action back to pending and execute it, but the status store is documented here as monotonic, so the later processing update is a backwards transition from terminal expired and can be rejected/ignored. In that late-approval path CodeWatch can keep showing the action as expired while it is actually running or has succeeded.

Useful? React with 👍 / 👎.

error: action.status === 'failed' ? action.output_summary : undefined,
decided_at: action.decided_at,
executed_at: action.ran_at,
});
}

function actionReceipt(action) {
Expand All @@ -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 });
Expand Down Expand Up @@ -850,6 +920,69 @@ export function composeAnnouncers(map) {
};
}

// --- chat-reply poller -------------------------------------------------------

// Watch a GroupMind room for "/approve <id>" / "/deny <id>" 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 <id>" 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
Expand Down
27 changes: 26 additions & 1 deletion src/mcp-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
waitForDecision,
listIntents,
startConfirmationsServer,
startChatReplyPoller,
configureActionStatusPush,
makeGroupmindAnnouncer,
makeCodewatchAnnouncer,
composeAnnouncers,
Expand Down Expand Up @@ -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 <id>" / "/deny <id>" 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'
Expand Down
Loading