docs(plugin-tinyplace): cursor⇄openhuman bidirectional bridge spike - #252
Conversation
Capture the throwaway prototype that proves a Cursor IDE agent can be driven into a live two-way tiny.place conversation with OpenHuman over the Signal relay, plus the findings that inform the real adapter: - forward (Cursor→OpenHuman) via beforeSubmitPrompt/afterAgentResponse hooks → SessionEnvelopeV1 DMs rendered as a `cursor` runtime. - reverse (OpenHuman→Cursor) via a daemon that pastes inbox DMs into the live GUI (clipboard + System Events), with echo-suppression and focus-restore. - findings: `stop → followup_message` is the only in-conversation injection channel; CGEventPostToPid can't reach a backgrounded Electron window; AX value-set doesn't register in React; concurrent FileSessionStore access corrupts the ratchet (→ HTTP 400), fixed with a cross-process lock; SDK ≥2.0.2 required for base58 bundle routing. Prototype only (README flags the security caveats + auto-approve tradeoff); nothing here ships. Complements the cursor adapter hardening (tinyhumansai#251). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@CodeGhost21 is attempting to deploy a commit to the Vezures Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThis prototype adds a bidirectional Cursor–OpenHuman bridge. Cursor hooks forward prompts, responses, and approval requests through encrypted TinyPlace messaging, while a daemon polls inbound messages and injects them into Cursor using macOS automation. Shared state, locking, setup, echo suppression, and operational findings are documented. ChangesCursor OpenHuman Bridge
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Cursor
participant hook.mjs
participant OpenHuman
participant daemon.mjs
Cursor->>hook.mjs: submit prompt, response, or approval request
hook.mjs->>OpenHuman: send encrypted envelope
OpenHuman-->>hook.mjs: approval decision when requested
OpenHuman-->>daemon.mjs: inbound message
daemon.mjs->>Cursor: paste and submit message
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1d1241244e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const msgs = await withLock(() => | ||
| agent.readMessages(client, signer, { limit: 20 }), | ||
| ); | ||
| const texts = msgs | ||
| .filter((m) => m.from === OPENHUMAN) |
There was a problem hiding this comment.
Fail fast when OPENHUMAN_ADDR is missing
If the daemon is started without the same OPENHUMAN_ADDR used during setup, it still calls readMessages before filtering by OPENHUMAN. The SDK's readMessages decrypts and acknowledges the inbox, so a missing or mistyped env var makes this poll permanently drain all queued DMs and then drop them because none match the empty/bad address; the daemon should validate OPENHUMAN before any destructive inbox read.
Useful? React with 👍 / 👎.
| // Give up waiting — proceed unlocked rather than drop the message. | ||
| return await fn(); |
There was a problem hiding this comment.
Keep Signal operations locked until acquired
When another hook/daemon process holds the lock for more than the default ~8 seconds, withLock runs the SDK operation without the mutex. The surrounding code notes that concurrent FileSessionStore access corrupts the Double Ratchet state, so any slow staging request or key fetch can reintroduce the exact 400-producing corruption this lock is meant to prevent; this should keep waiting or fail the operation rather than proceed unlocked.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs`:
- Around line 50-72: Update withLock so stale-lock recovery cannot remove a lock
held by a live operation: record the holder PID and verify that process is no
longer alive before deleting an expired LOCKDIR, or otherwise refresh the lock
timestamp throughout long-running fn executions. Preserve stale-lock cleanup for
crashed or abandoned holders while preventing concurrent access during
legitimate operations.
- Around line 139-162: Protect the read-modify-write operations in recordPush
and consumePush with the existing withLock mechanism or a dedicated lock shared
by both processes. Ensure each function reads, updates, and writes pushed.json
while holding the same lock, preserving the current TTL filtering, matching,
removal, and return behavior.
- Around line 166-188: The envelope() function emits the v1 session shape
without the required bucket field. Add the appropriate bucket value to the scope
object in envelope(), preserving the existing session identifiers and v1
envelope contract.
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/README.md`:
- Line 23: Update the architecture diagram fenced code block in the README to
specify the text language using a text fence, while preserving the diagram
content unchanged.
- Around line 110-115: Update the README concurrency statement to avoid claiming
that withLock fully prevents session corruption, or revise withLock in
common.mjs so timeout handling never invokes the SDK operation without the lock.
Ensure sustained contention fails or retries safely rather than allowing
concurrent FileSessionStore mutation.
- Around line 86-91: Align the README description with the behavior implemented
by hook.mjs: either update the stop/subagentStop handlers to emit the documented
followup_message result, or revise this section to label the mechanism as
historical rather than an available zero-dependency fallback. Keep the claim
that it is currently usable only if hook.mjs actually supports it.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: dc7ecff1-c952-4b98-8981-38712df6a41d
📒 Files selected for processing (6)
sdk/plugin-tinyplace/prototype/cursor-bridge/README.mdsdk/plugin-tinyplace/prototype/cursor-bridge/common.mjssdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjssdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjssdk/plugin-tinyplace/prototype/cursor-bridge/postkeys.swiftsdk/plugin-tinyplace/prototype/cursor-bridge/setup.mjs
| const LOCK_STALE_MS = 15000; | ||
|
|
||
| export async function withLock(fn, { retries = 200, delayMs = 40 } = {}) { | ||
| mkdirSync(HOME, { recursive: true }); | ||
| for (let i = 0; i < retries; i++) { | ||
| try { | ||
| mkdirSync(LOCKDIR); // atomic: throws EEXIST if held | ||
| try { | ||
| return await fn(); | ||
| } finally { | ||
| try { | ||
| rmdirSync(LOCKDIR); | ||
| } catch {} | ||
| } | ||
| } catch (e) { | ||
| if (e.code !== "EEXIST") throw e; | ||
| // Break a stale lock left by a crashed process. | ||
| try { | ||
| if (Date.now() - statSync(LOCKDIR).mtimeMs > LOCK_STALE_MS) { | ||
| rmdirSync(LOCKDIR); | ||
| continue; | ||
| } | ||
| } catch {} |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Stale-lock timeout can break a lock held by a live operation.
mkdir sets the lock dir's mtime once; it isn't refreshed while fn() runs. A legitimate SDK send/read that exceeds LOCK_STALE_MS (15s) — plausible on a slow relay round-trip — will be seen as stale by another process, which then rmdirSynces it and proceeds concurrently. That is exactly the concurrent store read/write this mutex exists to prevent (the documented → HTTP 400 ratchet corruption). Consider recording the holder PID and checking liveness, or refreshing mtime during long ops.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs` around lines 50 -
72, Update withLock so stale-lock recovery cannot remove a lock held by a live
operation: record the holder PID and verify that process is no longer alive
before deleting an expired LOCKDIR, or otherwise refresh the lock timestamp
throughout long-running fn executions. Preserve stale-lock cleanup for crashed
or abandoned holders while preventing concurrent access during legitimate
operations.
| export function recordPush(text) { | ||
| const arr = readPushed() | ||
| .filter((e) => Date.now() - e.ts < PUSH_TTL_MS) | ||
| .concat([{ text: String(text).trim(), ts: Date.now() }]) | ||
| .slice(-20); | ||
| try { | ||
| writeFileSync(PUSHED, JSON.stringify(arr), { mode: 0o600 }); | ||
| } catch {} | ||
| } | ||
|
|
||
| // Returns true (and removes the record) if `text` matches a recent push. | ||
| export function consumePush(text) { | ||
| const arr = readPushed(); | ||
| const t = String(text).trim(); | ||
| const i = arr.findIndex( | ||
| (e) => e.text === t && Date.now() - e.ts < PUSH_TTL_MS, | ||
| ); | ||
| if (i === -1) return false; | ||
| arr.splice(i, 1); | ||
| try { | ||
| writeFileSync(PUSHED, JSON.stringify(arr), { mode: 0o600 }); | ||
| } catch {} | ||
| return true; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Echo-suppression file is read-modify-written without the mutex.
recordPush (daemon) and consumePush (hook) both do unlocked read-modify-write on pushed.json from separate processes. A concurrent overlap can drop a just-recorded push record, so consumePush misses it and the pasted OpenHuman message gets echoed back → feedback loop. Consider serializing these under withLock (or a dedicated lock) as well.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs` around lines 139 -
162, Protect the read-modify-write operations in recordPush and consumePush with
the existing withLock mechanism or a dedicated lock shared by both processes.
Ensure each function reads, updates, and writes pushed.json while holding the
same lock, preserving the current TTL filtering, matching, removal, and return
behavior.
| export function envelope({ role, text, convId, cwd }) { | ||
| const sid = convId || "cursor-session"; | ||
| return JSON.stringify({ | ||
| envelope_version: "tinyplace.harness.session.v1", | ||
| version: 1, | ||
| scope: { | ||
| type: "session", | ||
| key: "cursor", | ||
| cwd: cwd || "", | ||
| wrapper_session_id: sid, | ||
| harness_session_id: sid, | ||
| }, | ||
| harness: { provider: "cursor", command: "cursor", argv: [] }, | ||
| message: { | ||
| id: `${sid}-${Date.now()}`, | ||
| line: Date.now(), | ||
| role, | ||
| text, | ||
| timestamp: new Date().toISOString(), | ||
| }, | ||
| source: { path: "cursor", record_type: role }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -nP '\.bucket\b' sdk/typescript/src | rg -i 'envelope|session' -C2
ast-grep run --pattern 'foldSessionEnvelopes($$$)' --lang typescript sdk/typescript/src || true
rg -n 'bucket' sdk/typescript/src/cli/harness-consumer.ts -C2Repository: tinyhumansai/tiny.place
Length of output: 1077
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the session envelope type and parser
sed -n '1,220p' sdk/typescript/src/types/harness.ts
printf '\n--- parseSessionEnvelope ---\n'
rg -n "parseSessionEnvelope|SessionEnvelopeV1|SessionEnvelope" sdk/typescript/src -C3
# Inspect the consumer/wrapper paths that dereference bucket
printf '\n--- harness-wrapper bucket references ---\n'
sed -n '960,1010p' sdk/typescript/src/cli/harness-wrapper.ts
printf '\n--- later bucket references ---\n'
sed -n '1568,1590p' sdk/typescript/src/cli/harness-wrapper.ts
# Find where envelope() is used, to understand the runtime path for cursor envelopes
printf '\n--- envelope() call sites ---\n'
rg -n "envelope\(" sdk -C2Repository: tinyhumansai/tiny.place
Length of output: 49155
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Read the Cursor bridge envelope builder and its caller
sed -n '1,260p' sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs
printf '\n--- hook.mjs ---\n'
sed -n '1,180p' sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs
# Find any code in the plugin prototype that parses or inspects this envelope shape
printf '\n--- cursor bridge references ---\n'
rg -n "cursor-session|SessionEnvelopeV1|envelope_version|bucket|parseSessionEnvelope|harness_type_for|cursor-bridge" sdk/plugin-tinyplace/prototype -C2
# Check whether any consumer path in the repo dereferences bucket on v1 cursor envelopes specifically
printf '\n--- bucket + cursor references ---\n'
rg -n "bucket\." sdk | rg -i "cursor|SessionEnvelopeV1|parseSessionEnvelope|harness-consumer|harness-wrapper" -C2Repository: tinyhumansai/tiny.place
Length of output: 16768
envelope() omits required bucket for the v1 session shape.
sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs:166-188 SessionEnvelopeV1 requires bucket, so this payload doesn’t match the declared contract. Add it here, or stop emitting a v1 envelope shape.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs` around lines 166 -
188, The envelope() function emits the v1 session shape without the required
bucket field. Add the appropriate bucket value to the scope object in
envelope(), preserving the existing session identifiers and v1 envelope
contract.
|
|
||
| ## Architecture | ||
|
|
||
| ``` |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Specify the fenced block language.
Use ```text for the architecture diagram; markdownlint MD040 currently flags this fence.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 23-23: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/README.md` at line 23, Update
the architecture diagram fenced code block in the README to specify the text
language using a text fence, while preserving the diagram content unchanged.
Source: Linters/SAST tools
Extend the cursor⇄openhuman bridge prototype with the tool-approval feature and transport hardening validated against staging: - hook.mjs: route beforeShellExecution/beforeMCPExecution to OpenHuman as a v2 approval_request event and block for the allow/deny decision (falls back to Cursor's own prompt on timeout); auto-allow file reads. - common.mjs: v2 approvalEnvelope builder, extractText, an AWAITING flag (daemon pauses inbox draining while an approval is pending), and sendWithRetry which self-heals a desynced session (reset + retry on a 400/encrypt error). - daemon.mjs: pause while an approval is pending so the hook owns the decision DM. - README: approval-routing section + findings on the two-store ratchet fragility (receiving side can't retry a silent drop) and deriving the resolved-card state. Prototype only; kept repo-portable (relative SDK dist, OPENHUMAN_ADDR from env). Pairs with the OpenHuman Allow/Deny card PR (tinyhumansai/openhuman#4837). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs (1)
147-171: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winBroad error-matching regex risks unwarranted session resets.
/encrypted ciphertext|HTTP 400|No session|ratchet|decrypt/imatches on bare substrings like"HTTP 400"or"decrypt", which can appear in unrelated errors (rate limiting, malformed payloads, other 400s). Any such error triggersstore.removeSession(to), forcing an unnecessary re-handshake and masking the real cause of the failure — compounding the "two-store ratchet fragility" already called out for this prototype.♻️ Narrow the match to session/ratchet-specific signals
- if ( - !/encrypted ciphertext|HTTP 400|No session|ratchet|decrypt/i.test( - String(e?.message), - ) - ) { + if ( + !/body must be encrypted ciphertext|No session|ratchet desync|failed to decrypt/i.test( + String(e?.message), + ) + ) { throw e; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs` around lines 147 - 171, Update the error filter in sendWithRetry to match only explicit session, ratchet, or encrypted-ciphertext failure signals, avoiding broad substrings such as bare “HTTP 400” or “decrypt” that can represent unrelated errors. Preserve rethrowing non-matching errors and the existing session reset and single retry behavior for matched failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs`:
- Around line 240-279: Update approvalEnvelope to include the required top-level
bucket field in the SessionEnvelopeV2 JSON payload, using the same bucket value
expected by the existing v2 session envelopes. Keep the current scope, event,
and source structure unchanged.
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs`:
- Around line 29-32: Clamp the BRIDGE_APPROVAL_WAIT_MS override in the
APPROVAL_WAIT_MS definition so its effective value remains safely below the
daemon’s 360,000 ms stale cutoff. Preserve the existing 270,000 ms default and
ensure oversized environment values cannot let routeApproval() poll past the
cutoff.
- Around line 64-113: Update routeApproval to poll only messages correlated with
its approval request, using requestId/call_id metadata before parsing
allow/deny; leave unrelated OpenHuman messages available for Cursor or other
handlers. Ensure overlapping approvals cannot consume each other’s decisions
while preserving the existing timeout and fallback behavior.
---
Nitpick comments:
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs`:
- Around line 147-171: Update the error filter in sendWithRetry to match only
explicit session, ratchet, or encrypted-ciphertext failure signals, avoiding
broad substrings such as bare “HTTP 400” or “decrypt” that can represent
unrelated errors. Preserve rethrowing non-matching errors and the existing
session reset and single retry behavior for matched failures.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 84a93555-0214-405f-8c68-f1d3b99bf6bf
📒 Files selected for processing (4)
sdk/plugin-tinyplace/prototype/cursor-bridge/README.mdsdk/plugin-tinyplace/prototype/cursor-bridge/common.mjssdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjssdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs
🚧 Files skipped from review as they are similar to previous changes (2)
- sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs
- sdk/plugin-tinyplace/prototype/cursor-bridge/README.md
|
|
||
| // SessionEnvelopeV2 with a typed `approval_request` event. OpenHuman's orchestration | ||
| // ingest (classify_v2) maps this to eventKind "approval_request" (display → body, | ||
| // tool_name, call_id) and the SessionTranscript renders an Allow/Deny card. Uses the | ||
| // SAME wrapper_session_id as the chat turns so it threads into the same session; the | ||
| // user's button reply comes back as a plain "allow"/"deny" DM. | ||
| export function approvalEnvelope({ | ||
| toolName, | ||
| display, | ||
| convId, | ||
| cwd, | ||
| requestId, | ||
| }) { | ||
| const sid = convId || "cursor-session"; | ||
| return JSON.stringify({ | ||
| envelope_version: "tinyplace.harness.session.v2", | ||
| version: 2, | ||
| scope: { | ||
| type: "session", | ||
| key: "cursor", | ||
| cwd: cwd || "", | ||
| wrapper_session_id: sid, | ||
| harness_session_id: sid, | ||
| }, | ||
| harness: { provider: "cursor", command: "cursor", argv: [] }, | ||
| event: { | ||
| id: requestId, | ||
| seq: Date.now(), | ||
| ts: new Date().toISOString(), | ||
| role: "agent", | ||
| kind: "approval_request", | ||
| payload: { | ||
| tool_name: toolName || "shell", | ||
| display: display || "", | ||
| call_id: requestId, | ||
| }, | ||
| }, | ||
| source: { path: "cursor", record_type: "approval_request" }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check SessionEnvelopeV2 scope requirements
rg -n "SessionEnvelopeV2" sdk/typescript/src -C6
rg -n "interface.*Scope|type.*Scope" sdk/typescript/src/types/harness.ts -C10Repository: tinyhumansai/tiny.place
Length of output: 11693
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the approval envelope implementation and nearby helpers.
FILE="sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs"
wc -l "$FILE"
sed -n '200,320p' "$FILE"
# Inspect the v2 envelope type and any builders that populate bucket.
sed -n '190,240p' sdk/typescript/src/types/harness.ts
sed -n '1,140p' sdk/typescript/src/cli/harness-envelope.tsRepository: tinyhumansai/tiny.place
Length of output: 7492
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find where approvalEnvelope is used and whether any v2 envelope JSON is produced without bucket elsewhere.
rg -n "approvalEnvelope\(|tinyplace\.harness\.session\.v2|record_type: \"approval_request\"|approval_request" sdk/plugin-tinyplace -C3Repository: tinyhumansai/tiny.place
Length of output: 9245
Add the required top-level bucket to the v2 approval envelope.
SessionEnvelopeV2 includes bucket at the top level, and approvalEnvelope() omits it entirely. That makes this payload fail the v2 schema/ingest contract on the approval path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs` around lines 240 -
279, Update approvalEnvelope to include the required top-level bucket field in
the SessionEnvelopeV2 JSON payload, using the same bucket value expected by the
existing v2 session envelopes. Keep the current scope, event, and source
structure unchanged.
| // How long the approval hook waits for an OpenHuman decision before falling back | ||
| // to Cursor's own GUI prompt. MUST be < the hook's timeout in ~/.cursor/hooks.json | ||
| // (300s there → 270s here leaves a buffer so we return before Cursor kills us). | ||
| const APPROVAL_WAIT_MS = Number(process.env.BRIDGE_APPROVAL_WAIT_MS) || 270_000; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm the daemon's staleness cutoff constant hasn't drifted from what's assumed here
rg -n "360_000|approvalPending" sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs -C4Repository: tinyhumansai/tiny.place
Length of output: 980
🏁 Script executed:
#!/bin/bash
ast-grep outline sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs --view expanded
rg -n "BRIDGE_APPROVAL_WAIT_MS|routeApproval|withLock|AWAITING|approvalPending|session store|inbox" sdk/plugin-tinyplace/prototype/cursor-bridge -C4
sed -n '1,220p' sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjsRepository: tinyhumansai/tiny.place
Length of output: 30142
🏁 Script executed:
#!/bin/bash
sed -n '107,170p' sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjsRepository: tinyhumansai/tiny.place
Length of output: 992
Cap BRIDGE_APPROVAL_WAIT_MS below the daemon stale cutoff. If it exceeds 360_000 ms, daemon.mjs can resume draining the inbox while routeApproval() is still polling, and the OpenHuman allow/deny reply may get consumed before the hook sees it.
🔒 Clamp the override to stay safely under the daemon cutoff
-const APPROVAL_WAIT_MS = Number(process.env.BRIDGE_APPROVAL_WAIT_MS) || 270_000;
+// Must stay comfortably below daemon.mjs's approvalPending() stale cutoff (360s),
+// or the daemon will resume draining the inbox while we're still polling it.
+const APPROVAL_WAIT_MS = Math.min(
+ Number(process.env.BRIDGE_APPROVAL_WAIT_MS) || 270_000,
+ 300_000,
+);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // How long the approval hook waits for an OpenHuman decision before falling back | |
| // to Cursor's own GUI prompt. MUST be < the hook's timeout in ~/.cursor/hooks.json | |
| // (300s there → 270s here leaves a buffer so we return before Cursor kills us). | |
| const APPROVAL_WAIT_MS = Number(process.env.BRIDGE_APPROVAL_WAIT_MS) || 270_000; | |
| // How long the approval hook waits for an OpenHuman decision before falling back | |
| // to Cursor's own GUI prompt. MUST be < the hook's timeout in ~/.cursor/hooks.json | |
| // (300s there → 270s here leaves a buffer so we return before Cursor kills us). | |
| // Must stay comfortably below daemon.mjs's approvalPending() stale cutoff (360s), | |
| // or the daemon will resume draining the inbox while we're still polling it. | |
| const APPROVAL_WAIT_MS = Math.min( | |
| Number(process.env.BRIDGE_APPROVAL_WAIT_MS) || 270_000, | |
| 300_000, | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs` around lines 29 - 32,
Clamp the BRIDGE_APPROVAL_WAIT_MS override in the APPROVAL_WAIT_MS definition so
its effective value remains safely below the daemon’s 360,000 ms stale cutoff.
Preserve the existing 270,000 ms default and ensure oversized environment values
cannot let routeApproval() poll past the cutoff.
| // Route a Cursor tool-execution gate to OpenHuman: post the request, then block | ||
| // (draining the inbox) until the user replies allow/deny there — so approvals | ||
| // happen in OpenHuman without switching to Cursor. Returns "allow" | "deny" | | ||
| // "ask" (fallback to Cursor's own prompt on timeout/error). While waiting we hold | ||
| // the AWAITING flag so the reverse daemon doesn't steal the decision DM. | ||
| async function routeApproval(payload, ev, convId, cwd) { | ||
| const label = describeCall(payload, ev); | ||
| const toolName = ev === "beforeShellExecution" ? "shell" : "mcp"; | ||
| const requestId = `appr-${Date.now()}`; | ||
| const { signer, client, agent, store } = await build(); | ||
| writeFileSync(AWAITING, String(Date.now())); | ||
| try { | ||
| // V2 approval_request event → OpenHuman renders an Allow/Deny card; the user's | ||
| // button reply comes back as a plain "allow"/"deny" DM we parse below. | ||
| await withLock(() => | ||
| sendWithRetry( | ||
| { client, signer, agent, store }, | ||
| OPENHUMAN, | ||
| approvalEnvelope({ toolName, display: label, convId, cwd, requestId }), | ||
| ), | ||
| ); | ||
| log(`APPROVAL request -> OH (${requestId}): ${String(label).slice(0, 80)}`); | ||
| const deadline = Date.now() + APPROVAL_WAIT_MS; | ||
| while (Date.now() < deadline) { | ||
| const msgs = await withLock(() => | ||
| agent.readMessages(client, signer, { limit: 10 }), | ||
| ); | ||
| for (const m of msgs) { | ||
| if (m.from !== OPENHUMAN) continue; | ||
| const d = parseDecision(extractText(m.text)); | ||
| if (d) { | ||
| log(`APPROVAL decision=${d} for ${String(label).slice(0, 50)}`); | ||
| return d; | ||
| } | ||
| } | ||
| await sleep(2000); | ||
| } | ||
| log( | ||
| `APPROVAL timed out -> ask (Cursor GUI fallback): ${String(label).slice(0, 50)}`, | ||
| ); | ||
| return "ask"; | ||
| } catch (e) { | ||
| log(`APPROVAL error -> ask: ${e.message}`); | ||
| return "ask"; | ||
| } finally { | ||
| try { | ||
| rmSync(AWAITING); | ||
| } catch {} | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
set -euo pipefail
printf '\n## Files\n'
git ls-files 'sdk/plugin-tinyplace/prototype/cursor-bridge/*' | sed -n '1,200p'
printf '\n## Outline hook.mjs\n'
ast-grep outline sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs --view expanded || true
printf '\n## Outline daemon.mjs\n'
ast-grep outline sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs --view expanded || true
printf '\n## Search for approval/requestId/parseDecision/readMessages\n'
rg -n --hidden --glob 'sdk/plugin-tinyplace/prototype/cursor-bridge/*' \
'requestId|parseDecision|readMessages|approvalEnvelope|AWAITING|OPENHUMAN|allow|deny' \
sdk/plugin-tinyplace/prototype/cursor-bridgeRepository: tinyhumansai/tiny.place
Length of output: 6930
🏁 Script executed:
set -euo pipefail
printf '\n## hook.mjs selected lines\n'
nl -ba sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs | sed -n '1,220p'
printf '\n## daemon.mjs selected lines\n'
nl -ba sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs | sed -n '1,260p'Repository: tinyhumansai/tiny.place
Length of output: 229
🏁 Script executed:
set -euo pipefail
printf '\n## Search for message queue semantics and request correlation\n'
rg -n --hidden --glob 'sdk/plugin-tinyplace/prototype/cursor-bridge/*' \
'readMessages\(|sendWithRetry\(|approval_request|approvalEnvelope|requestId|conversation|convId|parseDecision|extractText|from !== OPENHUMAN|AWAITING' \
sdk/plugin-tinyplace/prototype/cursor-bridgeRepository: tinyhumansai/tiny.place
Length of output: 5477
🏁 Script executed:
set -euo pipefail
printf '\n## hook.mjs relevant excerpt\n'
sed -n '1,220p' sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs | nl -ba | sed -n '1,220p'
printf '\n## daemon.mjs relevant excerpt\n'
sed -n '1,260p' sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs | nl -ba | sed -n '1,260p'
printf '\n## Search related helpers\n'
rg -n --hidden --glob 'sdk/plugin-tinyplace/prototype/cursor-bridge/*' \
'function parseDecision|const parseDecision|function extractText|const extractText|approvalEnvelope|approval_request|readMessages\(' \
sdk/plugin-tinyplace/prototype/cursor-bridgeRepository: tinyhumansai/tiny.place
Length of output: 231
🏁 Script executed:
set -euo pipefail
# Show the relevant files and correlations.
for f in sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs \
sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs \
sdk/plugin-tinyplace/prototype/cursor-bridge/*.mjs
do
[ -f "$f" ] && printf '\n### %s\n' "$f" && nl -ba "$f" | sed -n '1,260p'
done
printf '\n### Search for requestId and approval routing\n'
rg -n --hidden --glob 'sdk/plugin-tinyplace/prototype/cursor-bridge/*' \
'requestId|approvalEnvelope|parseDecision|extractText|readMessages\(|withLock\(|AWAITING|OPENHUMAN|beforeShellExecution|beforeMcp' \
sdk/plugin-tinyplace/prototype/cursor-bridgeRepository: tinyhumansai/tiny.place
Length of output: 260
🏁 Script executed:
set -euo pipefail
printf '\n### Files mentioning readMessages or agent methods\n'
rg -n --hidden --glob '!**/node_modules/**' 'readMessages\s*\(' .
printf '\n### Files mentioning parseDecision/extractText\n'
rg -n --hidden --glob '!**/node_modules/**' 'parseDecision|extractText' .Repository: tinyhumansai/tiny.place
Length of output: 3303
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
files = [
"sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs",
"sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs",
"sdk/typescript/src/agent/messaging.ts",
]
for path in files:
print(f"\n### {path}")
lines = Path(path).read_text().splitlines()
for i, line in enumerate(lines, 1):
if path.endswith("common.mjs") and 1 <= i <= 320:
if i in list(range(1, 120)) + list(range(150, 290)):
print(f"{i:4d}: {line}")
elif path.endswith("daemon.mjs") and 1 <= i <= 220:
if i in list(range(1, 170)):
print(f"{i:4d}: {line}")
elif path.endswith("messaging.ts") and 1 <= i <= 260:
if i in list(range(1, 240)):
print(f"{i:4d}: {line}")
PYRepository: tinyhumansai/tiny.place
Length of output: 21581
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
def show(path, ranges):
print(f"\n### {path}")
lines = Path(path).read_text().splitlines()
for start, end in ranges:
for i in range(start, min(end, len(lines)) + 1):
print(f"{i:4d}: {lines[i-1]}")
print()
show("sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs", [(1,120), (140,290)])
show("sdk/plugin-tinyplace/prototype/cursor-bridge/daemon.mjs", [(1,170)])
show("sdk/typescript/src/agent/messaging.ts", [(1,240)])
PYRepository: tinyhumansai/tiny.place
Length of output: 22039
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
path = Path("sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs")
lines = path.read_text().splitlines()
for start, end in [(115, 220)]:
print(f"\n### {path} {start}-{end}")
for i in range(start, min(end, len(lines)) + 1):
print(f"{i:4d}: {lines[i-1]}")
PYRepository: tinyhumansai/tiny.place
Length of output: 3370
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
for path in [
"sdk/plugin-tinyplace/prototype/cursor-bridge/README.md",
"sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs",
"sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs",
]:
print(f"\n### {path}")
text = Path(path).read_text().splitlines()
for i, line in enumerate(text, 1):
if any(k in line.lower() for k in ["approval", "parallel", "concurrent", "one at a time", "requestid", "call_id", "allow/deny", "awaiting"]):
print(f"{i:4d}: {line}")
PYRepository: tinyhumansai/tiny.place
Length of output: 3936
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
for path in [
"sdk/plugin-tinyplace/prototype/cursor-bridge/README.md",
"sdk/plugin-tinyplace/prototype/cursor-bridge/common.mjs",
"sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs",
]:
print(f"\n### {path}")
lines = Path(path).read_text().splitlines()
for i, line in enumerate(lines, 1):
low = line.lower()
if any(k in low for k in ["approval", "parallel", "concurrent", "one at a time", "requestid", "call_id", "allow/deny", "awaiting"]):
print(f"{i:4d}: {line}")
PYRepository: tinyhumansai/tiny.place
Length of output: 3936
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
path = Path("sdk/plugin-tinyplace/prototype/cursor-bridge/README.md")
lines = path.read_text().splitlines()
for start, end in [(1, 90), (120, 170)]:
print(f"\n### {path} {start}-{end}")
for i in range(start, min(end, len(lines)) + 1):
print(f"{i:4d}: {lines[i-1]}")
PYRepository: tinyhumansai/tiny.place
Length of output: 8732
Approval polling needs inbox filtering and request correlation
routeApproval()drainsagent.readMessages()while waiting forallow/deny, so any other OpenHuman DM that arrives in that window is acknowledged here and never reaches Cursor.- The reply is matched by text only;
requestId/call_idis never checked, so overlapping approvals can consume the wrongallow/deny.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@sdk/plugin-tinyplace/prototype/cursor-bridge/hook.mjs` around lines 64 - 113,
Update routeApproval to poll only messages correlated with its approval request,
using requestId/call_id metadata before parsing allow/deny; leave unrelated
OpenHuman messages available for Cursor or other handlers. Ensure overlapping
approvals cannot consume each other’s decisions while preserving the existing
timeout and fallback behavior.
Summary
Captures the throwaway prototype that proves a Cursor IDE agent can be driven into a live, two-way tiny.place conversation with OpenHuman over the Signal-encrypted relay — and, more importantly, records the findings that inform the real
adapters/cursor.mjs. Lands undersdk/plugin-tinyplace/prototype/cursor-bridge/(scripts + a findings README). Nothing here ships.beforeSubmitPrompt/afterAgentResponsehooks send each turn as aSessionEnvelopeV1DM; OpenHuman renders it as acursorruntime session.System Events), so they appear in the chat and get answered — answer flows back to OpenHuman. Full loop, demoed live against staging.Problem
The recognition slice + adapter work needed to know what's actually achievable for a Cursor runtime end-to-end. Rather than guess, this spike built the whole loop against staging and wrote down what works and what doesn't.
Findings (the point of the PR)
stop → followup_messageis the only in-conversation injection channel Cursor exposes (hooks aren't observe-only). It's turn-triggered, not push.CGEventPostToPidcan't reach a backgrounded Electron window — Chromium drops synthetic key events unless it's the key window (postkeys.swiftkept as evidence). So there is no zero-focus-steal instant push; instant delivery needs a brief foreground flash (we capture+restore the prior app), or inject only while Cursor is already frontmost.FileSessionStoreaccess corrupts the Double Ratchet → HTTP 400 on send; fixed with a cross-process mkdir lock (withLock).Solution
Six files under
prototype/cursor-bridge/(common.mjs,hook.mjs,daemon.mjs,setup.mjs,postkeys.swift,README.md). SDK dist path is resolved relative to the file (repo-portable); OpenHuman address + API come from env. README documents setup and the security caveats.Impact
~/.tinyplace-cursorbridge.Related
🤖 Generated with Claude Code
Summary by CodeRabbit