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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ apps/microbridge-ui/src-tauri/binaries/
# local tooling / debug artifacts (not part of the public project)
.playwright-mcp/
/settings-keys-twin.png

# Cursor IDE hook runtime state (machine-local paths; do not commit)
.cursor/hooks/state/
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Learned User Preferences

- Aim for Claude-parity Cursor integration (approve/reject/interrupt/focus/new session), not lifecycle-only Limited status.
- When shipping a Microbridge release, confirm version bumps and that DMG (and other release) assets were published—not only that the PR merged.
- Do not admin-bypass failing required CI on Microbridge merges; CodeRabbit rate-limit failures alone are non-blocking.

## Learned Workspace Facts

- Local checkout `t3PRHelp` is the GitHub repo `DevVig/microbridge` (Tauri UI in `apps/microbridge-ui`, daemon in `crates/microbridged`).
- Integrations split into community adapters (Cursor, Factory, T3 Code, OpenCode) and host-attributed/native hosts (Claude Code, Codex, Synara, CNVS); host-attributed names appear on sessions and may not get separate Integrations tiles.
- Claude reaches full control via FSEvents on `~/.claude/projects` journals; Cursor historically used hooks-only `ingest_lifecycle` (Limited)—ACP is the intended path toward Claude-parity.
- Community adapter “Setup needed” / “Waiting” usually means hooks or the host app are not yet delivering lifecycle events, not only a UI selection bug.
- Release pipeline is tag-driven: GitHub Release (DMGs/tarballs) → Homebrew formula bump PR → `finalize-release` smoke/promote; Intel finalize must use `brew services list --json` (not pipe-to-awk/grep) to avoid Broken pipe flakes.
42 changes: 42 additions & 0 deletions adapters/claude/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Claude Code hooks for Microbridge

Push-only PermissionRequest bridge. No daemon polling.

## Install

Microbridge Settings can merge hooks into `~/.claude/settings.json`, or add:

```json
{
"hooks": {
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "node \"/path/to/microbridge/adapters/claude/hooks/microbridge-permission.mjs\" permission"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "node \"/path/to/microbridge/adapters/claude/hooks/microbridge-permission.mjs\" pretool"
}
]
}
]
}
}
```

When enabling the Claude integration, Microbridge installs a copy under
`~/.microbridge/claude-hooks/` and points settings at that path.

## Privacy

Only session id + lifecycle state are sent to the local Microbridge socket.
Tool arguments and prompt text are not forwarded.
190 changes: 190 additions & 0 deletions adapters/claude/hooks/microbridge-permission.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#!/usr/bin/env node
/**
* Claude Code PermissionRequest / PreToolUse bridge for Microbridge.
* Zero idle cost: only runs when Claude invokes the hook.
*
* Pending approvals: ~/.microbridge/claude-pending/<id>.json
* Decisions: ~/.microbridge/claude-pending/<id>.decision (or latest.decision)
*/

import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";

const PENDING = path.join(os.homedir(), ".microbridge", "claude-pending");
const SOCKET =
process.env.MICROBRIDGE_SOCKET ||
path.join(os.homedir(), ".microbridge", "microbridged.sock");

async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const text = Buffer.concat(chunks).toString("utf8").trim();
if (!text) return {};
try {
return JSON.parse(text);
} catch {
return {};
}
}

function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}

function sessionId(input) {
return (
input.session_id ||
input.sessionId ||
input.conversation_id ||
input.transcript_path ||
"latest"
);
}

function ingestLifecycle(id, state) {
return new Promise((resolve) => {
const socket = net.createConnection(SOCKET);
const finish = () => {
socket.destroy();
resolve();
};
socket.setTimeout(800, finish);
socket.on("error", finish);
socket.on("close", finish);
socket.on("connect", () => {
const messages = [
{
type: "hello",
adapter: "claude-hook",
protocol_version: 0,
role: "ui",
adapter_version: "0.3.7",
capabilities: {},
},
{
type: "ingest_lifecycle",
adapter_id: "claude",
session: {
id: `claude:${id}`,
app: "Claude Code",
title: "Claude · awaiting approval",
state,
updated_at_ms: Date.now(),
focus_uri: null,
},
ttl_ms: 10 * 60 * 1000,
},
];
for (const message of messages) {
socket.write(`${JSON.stringify(message)}\n`);
}
// Fire-and-forget: daemon does not reply; close so we do not wait on timeout.
socket.end();
});
});
}

async function waitDecision(id, timeoutMs) {
ensureDir(PENDING);
const specific = path.join(PENDING, `${id}.decision`);
const latest = path.join(PENDING, "latest.decision");
const interruptFlag = path.join(PENDING, "interrupt", `${id}.flag`);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
for (const file of [specific, latest]) {
try {
if (fs.existsSync(file)) {
const decision = fs.readFileSync(file, "utf8").trim();
fs.unlinkSync(file);
return decision;
}
} catch {
/* ignore */
}
}
try {
if (fs.existsSync(interruptFlag)) {
fs.unlinkSync(interruptFlag);
return "deny_interrupt";
}
} catch {
/* ignore */
}
await delay(50);
}
return null;
}

function decisionOutput(hookEventName, decision) {
if (decision === "allow") {
return {
hookSpecificOutput: {
hookEventName,
decision: { behavior: "allow" },
},
};
}
if (decision === "deny_interrupt") {
return {
hookSpecificOutput: {
hookEventName,
decision: {
behavior: "deny",
message: "Interrupted from Microbridge",
interrupt: true,
},
},
};
}
return {
hookSpecificOutput: {
hookEventName,
decision: {
behavior: "deny",
message: "Rejected from Microbridge",
},
},
};
}

const mode = process.argv[2] || "permission";
const input = await readStdin();
const id = String(sessionId(input)).replace(/[^a-zA-Z0-9._-]/g, "_");

if (mode === "pretool") {
const interruptFlag = path.join(PENDING, "interrupt", `${id}.flag`);
if (fs.existsSync(interruptFlag)) {
try {
fs.unlinkSync(interruptFlag);
} catch {
/* ignore */
}
process.stdout.write(
JSON.stringify(decisionOutput("PreToolUse", "deny_interrupt")),
);
process.exit(0);
}
process.stdout.write("{}");
process.exit(0);
}

ensureDir(PENDING);
fs.writeFileSync(
path.join(PENDING, `${id}.json`),
JSON.stringify({ id, at: Date.now(), tool: input.tool_name || null }),
);
await ingestLifecycle(id, "awaiting_approval");

const decision = await waitDecision(id, 10 * 60 * 1000);
if (!decision) {
// Timeout: let Claude's normal UI handle it (do not silently deny).
process.stdout.write("{}");
process.exit(0);
}

process.stdout.write(
JSON.stringify(decisionOutput("PermissionRequest", decision)),
);
29 changes: 29 additions & 0 deletions adapters/cursor-acp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Cursor Agent (ACP)

Separate from the **Cursor** IDE Composer tile. This integration drives
[Cursor ACP](https://cursor.com/docs/cli/acp) (`agent acp`) for agents that
Microbridge owns — not the already-open IDE chat panel.

## Enable

1. Install the Cursor CLI so `agent` or `cursor-agent` is on `PATH`.
2. Microbridge Settings → Integrations → **Cursor Agent (ACP)** → Enable.
3. Use **New Session** / Interrupt / Approve / Reject from Microbridge when a
session is bound to this adapter.

## Capabilities (when CLI is present)

| Action | ACP method |
|---|---|
| New session | `session/new` |
| Interrupt | `session/cancel` |
| Approve | `session/request_permission` → `allow-once` |
| Reject | `session/request_permission` → `reject-once` |

IDE Composer open/focus by `conversation_id` remains unavailable from public
Cursor APIs; keep using the Cursor tile for lifecycle + workspace deep links.

## Privacy

ACP traffic stays on the local machine. Prompt content is not mirrored onto the
Microbridge status bus unless a future opt-in is added.
15 changes: 10 additions & 5 deletions adapters/cursor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,16 @@ Microbridge app.

## Capability boundary

Lifecycle observation is implemented. Cursor does not currently expose stable
public APIs for authoritative approval acceptance, session-scoped interrupt,
opening an existing thread, or reasoning-effort changes. Microbridge therefore
reports this adapter as **Limited** and never falls back to private storage or
Accessibility scripting.
Lifecycle observation is implemented (hooks + optional transcript watch). Cursor
IDE does not currently expose stable public APIs for authoritative approval
acceptance, session-scoped interrupt of the composer, opening an existing
thread by id, or reasoning-effort changes. Microbridge therefore reports the
IDE Composer tile as **Connected** for lifecycle (same ceiling as Claude Code)
and never falls back to private storage or Accessibility scripting.

For hardware-driven approve / interrupt / new session against **Microbridge-owned**
Cursor agents, enable **Cursor Agent (ACP)** and install the Cursor CLI
(`agent` / `cursor-agent`). See [../cursor-acp/README.md](../cursor-acp/README.md).

Run a hook locally:

Expand Down
1 change: 1 addition & 0 deletions adapters/cursor/hooks/event.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const LIFECYCLE_STATES = {
idle: "idle",
stop: "idle",
session_end: "idle",
session_start: "thinking",
thinking: "thinking",
before_submit_prompt: "thinking",
after_agent_thought: "thinking",
Expand Down
3 changes: 3 additions & 0 deletions adapters/cursor/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{ "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" session_start" }
],
"beforeSubmitPrompt": [
{ "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" thinking" }
],
Expand Down
2 changes: 1 addition & 1 deletion apps/microbridge-ui/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading