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
27 changes: 25 additions & 2 deletions scripts/claude-gui-wake.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
#!/usr/bin/env bash
# claude-gui-wake.sh — wake Claude Code desktop app via osascript
# Requires: Accessibility permission for /Applications/Claude.app
# On failure (e.g. locked screen blocks bringing Claude to front), a detached
# retry loop re-attempts every 60s for up to 30 min — a failed nudge used to
# mean silence until the NEXT new message (2026-07-07 silent-hour incident).
set -euo pipefail
MSG="${1:-check rooms}"
LOCK="/tmp/claude-gui-wake.lock"
RETRY_LOCK="/tmp/claude-gui-wake.retry.lock"
LOG="${CLAUDE_GUI_WAKE_LOG:-/tmp/claude-gui-wake.log}"
NUDGE="${NUDGE_SCRIPT:-$(dirname "$0")/../tools/gemini_gui_nudge.sh}"
mkdir "$LOCK" 2>/dev/null || exit 0
trap "rmdir \"$LOCK\"" EXIT
{ printf "[%s] wake: %s\n" "$(date -u +%FT%TZ)" "$MSG"
IAK_NUDGE_TEXT="$MSG" "$NUDGE" 2>&1 && printf "[%s] sent\n" "$(date -u +%FT%TZ)" \
|| printf "[%s] failed\n" "$(date -u +%FT%TZ)"
if IAK_NUDGE_TEXT="$MSG" "$NUDGE" 2>&1; then
printf "[%s] sent\n" "$(date -u +%FT%TZ)"
else
printf "[%s] failed — starting 60s-interval retries (max 30)\n" "$(date -u +%FT%TZ)"
if mkdir "$RETRY_LOCK" 2>/dev/null; then
(
trap "rmdir \"$RETRY_LOCK\"" EXIT
for i in $(seq 1 30); do
sleep 60
if IAK_NUDGE_TEXT="$MSG" "$NUDGE" >>"$LOG" 2>&1; then
printf "[%s] retry #%s sent\n" "$(date -u +%FT%TZ)" "$i" >>"$LOG"
exit 0
fi
done
printf "[%s] retries exhausted\n" "$(date -u +%FT%TZ)" >>"$LOG"
) &
disown
else
printf "[%s] retry loop already active, piggybacking\n" "$(date -u +%FT%TZ)"
fi
fi
} >> "$LOG" 2>&1
86 changes: 81 additions & 5 deletions src/confirmations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,58 @@ export function decideIntent(id, decision, { receiptsPath } = {}) {
return { ok: true };
}

// Post a small threaded annotation on the original confirmation message so
// stale Approve/Deny buttons stop soliciting taps for commands that already
// ran (gate timeout defaults to allow). Best-effort.
function postGroupmindAnnotation(info, body) {
if (!info || !info.messageId || !info.room || !info.key) return Promise.resolve();
return import('node:https').then((https) => new Promise((resolve) => {
const data = JSON.stringify({
room: info.room,
body,
metadata: { reply_to: info.messageId },
});
Comment on lines +197 to +201

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 Send reply_to as a message field

For the timeout-allow annotation, this nests reply_to inside metadata, but the GroupMind message shape treats reply_to as a sibling of metadata (see skills/thinkoff-agent-platform/SKILL.md lines 109 and 118). In the /intent/:id/expire path the note will therefore not be threaded on the original confirmation card, so users tapping the stale Approve/Deny buttons still won't see the intended no-op explanation in context.

Useful? React with 👍 / 👎.

const r = https.request(
'https://groupmind.one/api/v1/messages',
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': info.key } },
(res) => { res.resume(); res.on('end', resolve); res.on('error', resolve); }
);
r.on('error', resolve);
r.write(data);
r.end();
}));
}

// Mark a pending intent as auto-allowed (the asker gave up waiting and
// proceeded by its default policy). Terminal like decideIntent, but records
// the distinct 'auto-allowed' decision and annotates the chat post so late
// taps are visibly no-ops. Idempotent.
export function expireIntent(id, { receiptsPath, timeoutSec } = {}) {
const i = intents.get(id);
if (!i) return { ok: false, error: `unknown intent ${id}` };
if (i.status !== 'pending') {
return { ok: true, idempotent: true, decision: i.decision };
}
i.status = 'decided';
i.decision = 'auto-allowed';
Comment on lines +223 to +224

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 Settle backing actions when expiring intents

When /intent/:id/expire is called for an intent created by /actions/request, this only marks the intent as decided; the corresponding entry in the actions map remains pending until its separate expires_at timer. That leaves /actions/:nonce reporting pending while /intents reports auto-allowed, and later Approve/Deny taps cannot settle or run the action because decideIntent now rejects the already-decided intent. Either reject action-backed intents here or settle the action record in the same path.

Useful? React with 👍 / 👎.

i.decidedAt = Date.now();
postReceipt(receiptsPath, {
kind: 'intent.auto_allowed', id, decidedAt: i.decidedAt, prompt: i.prompt,
});
for (const r of i.resolvers) {
try { r({ decision: 'auto-allowed', id }); } catch {}
}
i.resolvers = [];
pushStatus(id, 'expired', {
decision: 'auto-allowed',
decided_at: new Date(i.decidedAt).toISOString(),
});
const secs = Number.isFinite(Number(timeoutSec)) ? Number(timeoutSec) : null;
const note = `⏱ Auto-allowed${secs ? ` after ${secs}s` : ''} — the command already ran; the Approve/Deny buttons above are inactive, no action needed.`;
postGroupmindAnnotation(i.announceResults?.groupmind, note).catch(() => {});
return { ok: true };
}

// Create + announce a confirmation intent. Returns intent id immediately.
// `announce` is an injectable side-effect (groupmindPost / codewatchPush) for
// testability — production code passes the real posters.
Expand Down Expand Up @@ -220,7 +272,7 @@ export async function createIntent({
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 });
intent.announceResults = await announce({ id, prompt, session, channels, fromHandle });
} catch (e) {
postReceipt(receiptsPath, {
kind: 'intent.announce_failed', id, error: e.message,
Expand Down Expand Up @@ -589,6 +641,22 @@ export function startConfirmationsServer({
return;
}
}
const mExpire = url.pathname.match(/^\/intent\/([^/]+)\/expire$/);
if (req.method === 'POST' && mExpire) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let payload = {};
try { payload = JSON.parse(body || '{}'); } catch {}
const result = expireIntent(mExpire[1], {
receiptsPath,
timeoutSec: payload.timeout_sec,
});
res.writeHead(result.ok ? 200 : 404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
});
return;
}
const m = url.pathname.match(/^\/intent\/([^/]+)\/decision$/);
if (req.method === 'POST' && m) {
const id = m[1];
Expand Down Expand Up @@ -935,9 +1003,15 @@ export function makeGroupmindAnnouncer({ apiKey, room, callbackBase, apiKeys })
'https://groupmind.one/api/v1/messages',
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': effectiveKey } },
(res) => {
// drain + resolve regardless; the message-id isn't useful here.
res.resume();
res.on('end', () => resolve());
// Capture the created message id: expireIntent uses it to post the
// auto-allowed annotation as a threaded reply on this exact post.
let respBody = '';
res.on('data', (c) => { respBody += c; });
res.on('end', () => {
let messageId = null;
try { messageId = JSON.parse(respBody)?.id || null; } catch {}
resolve({ messageId, room, key: effectiveKey });
});
res.on('error', reject);
}
);
Expand Down Expand Up @@ -975,14 +1049,16 @@ export function makeCodewatchAnnouncer({ gateUrl, gateToken }) {
// Fan-out: build a single announce function from per-channel announcers.
export function composeAnnouncers(map) {
return async (intent) => {
const results = {};
for (const ch of intent.channels) {
const fn = map[ch];
if (!fn) continue;
try { await fn(intent); } catch (e) {
try { results[ch] = await fn(intent); } catch (e) {
// log but continue to other channels
process.stderr.write(`[iak-mcp] announce ${ch} failed: ${e.message}\n`);
}
}
return results;
};
}

Expand Down
Loading