Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
62b65d6
fix(android): PermissionRequest hook timeout now updates on existing …
itsdestin Jul 31, 2026
12e46b7
feat(permissions): staggered 2h/2h30m/3h timeout tiers, both platform…
itsdestin Jul 31, 2026
6aa2928
fix(permission-timeout): correct stale fail-open transcript line, add…
itsdestin Jul 31, 2026
f9fbdb5
fix(permission-timeout): correct deny-exit-code fiction in harness + …
itsdestin Jul 31, 2026
26935d8
fix(permission-timeout): correct flat decision shape in harness + han…
itsdestin Jul 31, 2026
2b2dd46
docs(relay): mark the spike's implementation plan as historical
itsdestin Jul 31, 2026
5273c91
feat(chat): PERMISSION_EXPIRED reasons — retain on hook-closed, quiet…
itsdestin Jul 31, 2026
18616c4
fix(chat): guard PERMISSION_CARD_RESOLVED against endTurn's stale exp…
itsdestin Jul 31, 2026
0aa8b27
fix(chat): preserve `expired` across the synthetic-permission-card merge
itsdestin Jul 31, 2026
fdfd1be
fix(chat): clear `expired` on tool-result settle, make TOOL_USE re-em…
itsdestin Jul 31, 2026
0f90dc9
feat(chat): expired cards resolve when the terminal menu leaves the b…
itsdestin Jul 31, 2026
fe92566
feat(chat): expired approval cards render Dismiss; delivery failures …
itsdestin Jul 31, 2026
b6369c0
feat(workbench): expired permission-card fixture
itsdestin Jul 31, 2026
9ed6f05
feat(main): app-owned 2h permission hold, 60s unroutable cap, reasone…
itsdestin Jul 31, 2026
5cad204
test(workbench): register permission_expired in the fixture-kind guard
itsdestin Jul 31, 2026
7ee4010
feat(android): EventBridge 2h permission hold + reasoned PermissionEx…
itsdestin Jul 31, 2026
b5341eb
feat(chat): digit rebind for expired permission cards (digit-gated, m…
itsdestin Jul 31, 2026
5fb40ac
feat(chat): send-refusal copy names the blocking approval card
itsdestin Jul 31, 2026
19f11d0
fix(chat): route no-session refusal fallback through shared copy helper
itsdestin Jul 31, 2026
3eaafb7
fix(permissions): close five review findings on the ask-timeout branch
itsdestin Jul 31, 2026
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
5 changes: 4 additions & 1 deletion app/src/main/assets/hook-relay-blocking.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
var net = require('net');
var socket = process.env.CLAUDE_MOBILE_SOCKET;
if (!socket) process.exit(0);
var TIMEOUT_MS = parseInt(process.env.CLAUDE_RELAY_TIMEOUT || '120000', 10);
// Tier-2 backstop: 2h30m — above EventBridge's 2h hold, below Bootstrap's 3h
// CC hook timeout. Relay-wins = exit 2 (clean deny); CC-wins = hook killed
// with no decision = AskUserQuestion wedges forever. Do NOT equalize (spec §1).
var TIMEOUT_MS = parseInt(process.env.CLAUDE_RELAY_TIMEOUT || '9000000', 10);

var input = '';
process.stdin.setEncoding('utf8');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ object HookSerializer {
return envelope("PermissionRequest", sessionId, inner)
}

fun permissionExpired(sessionId: String, requestId: String): JSONObject {
fun permissionExpired(sessionId: String, requestId: String, reason: String? = null): JSONObject {
val inner = JSONObject().apply {
put("_requestId", requestId)
// _reason rides inside the payload — same convention as desktop
// main.ts, so the shared hook-dispatcher parses both transports.
if (reason != null) put("_reason", reason)
}
return envelope("PermissionExpired", sessionId, inner)
}
Expand Down
80 changes: 76 additions & 4 deletions app/src/main/kotlin/com/youcoded/app/parser/EventBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,25 @@ import java.util.concurrent.ConcurrentHashMap
* structured decision back through it (blocking relay protocol).
*/
class EventBridge(private val socketName: String) {
companion object {
/** §1 tier-1 hold (2h). Must stay UNDER the relay asset's 2h30m and
* Bootstrap's 3h CC hook timeout — margins are load-bearing (the
* losing order kills the hook with no decision and AskUserQuestion
* wedges forever). Pinned by desktop/tests/permission-timeout-margins. */
const val PERMISSION_HOLD_MS = 7_200_000L
}

private val _events = MutableSharedFlow<HookEvent>(extraBufferCapacity = 1000)
val events: SharedFlow<HookEvent> = _events

/** Sockets held open for blocking PermissionRequest responses. */
private val pendingSockets = ConcurrentHashMap<String, LocalSocket>()

/** Tier-1 hold timers, keyed by requestId. Cancelled on every path that
* ends a request (respond, closeSocket, closure monitor, stop) so a
* 2h coroutine never outlives the socket it was guarding. */
private val holdJobs = ConcurrentHashMap<String, Job>()

/** Maps mobile session IDs to Claude Code session IDs. */
private val sessionIdMap = ConcurrentHashMap<String, String>()

Expand Down Expand Up @@ -119,6 +132,43 @@ class EventBridge(private val socketName: String) {
// hook-relay-blocking.js times out or Claude Code kills the hook.
// Desktop equivalent: hook-relay.ts socket.on('close') handler.
monitorSocketClosure(requestId, sessionId, client)

// §1 tier-1: the app ends the wait with a labeled deny.
// Must emit explicitly — respond() removes the pending
// entry BEFORE closing, so the closure monitor stays
// silent for app-initiated endings (its own comment).
monitorScope?.launch(Dispatchers.IO) {
delay(PERMISSION_HOLD_MS)
holdJobs.remove(requestId)
if (pendingSockets.containsKey(requestId)) {
// Nested decision shape is load-bearing: the relay
// reads appDecision.decision. Message lands in the
// tool result the model reads.
// Fix: derive from PERMISSION_HOLD_MS instead of a
// hardcoded "2 hours" — mirrors desktop's
// hook-relay.ts deriving from APP_HOLD_MS, so a
// future tier change can't silently desync the two
// platforms' copy (permission-timeout-margins.test.ts
// still pins the underlying millisecond values).
val holdHours = PERMISSION_HOLD_MS / 3_600_000L
val hoursLabel = if (holdHours == 1L) "hour" else "hours"
val deny = JSONObject().put("decision", JSONObject()
.put("behavior", "deny")
.put("message", "YouCoded auto-denied this request after $holdHours $hoursLabel with no user response — ask again if still needed."))
// Only emit "app-timeout" if the deny actually went out. If
// the write failed, respond() already emitted its own
// "delivery-failed" PermissionExpired — emitting again here
// would violate "at most one expiry per request".
if (respond(requestId, deny)) {
_events.tryEmit(HookEvent.PermissionExpired(
sessionId = sessionId,
hookEventName = "PermissionExpired",
requestId = requestId,
reason = "app-timeout",
))
}
}
}?.also { holdJobs[requestId] = it }
} else {
pendingSockets.remove(requestId)
client.close()
Expand All @@ -143,7 +193,7 @@ class EventBridge(private val socketName: String) {

/**
* Monitor a held PermissionRequest socket for remote closure.
* When hook-relay-blocking.js times out (120s) or Claude Code kills the hook
* When hook-relay-blocking.js times out (its 2h30m tier-2 backstop) or Claude Code kills the hook
* process, the socket closes. We detect this and emit PermissionExpired so
* the React UI can clear the stale approval card.
*
Expand All @@ -165,29 +215,44 @@ class EventBridge(private val socketName: String) {
// responded to — emit PermissionExpired to clean up the React UI.
if (pendingSockets.remove(requestId) != null) {
try { client.close() } catch (_: Exception) {}
// Far-end death (relay backstop or CC killing the hook), not our
// own hold firing — cancel the hold job so it doesn't also emit.
holdJobs.remove(requestId)?.cancel()
if (!_events.tryEmit(HookEvent.PermissionExpired(
sessionId = sessionId,
hookEventName = "PermissionExpired",
requestId = requestId,
reason = "hook-closed",
))) {
android.util.Log.e("EventBridge", "Event buffer full, dropped PermissionExpired")
}
}
}
}

/** Send a decision back through a held PermissionRequest socket. */
fun respond(requestId: String, decision: JSONObject) {
/**
* Send a decision back through a held PermissionRequest socket.
* Returns true if the write succeeded, false if it failed (in which case
* this method has ALREADY emitted a "delivery-failed" PermissionExpired
* itself — callers must not emit a second one for the same requestId, or
* "at most one expiry per request" breaks for the hold-timeout path).
*/
fun respond(requestId: String, decision: JSONObject): Boolean {
// A decision is about to be delivered (or attempted) — the tier-1
// hold is no longer needed. Cancel first so it can never race a
// second emit for the same request.
holdJobs.remove(requestId)?.cancel()
val socket = pendingSockets.remove(requestId)
if (socket == null) {
android.util.Log.e("EventBridge", "No pending socket for requestId=$requestId")
return
return false
}
try {
val payload = decision.toString() + "\n"
socket.outputStream.write(payload.toByteArray())
socket.outputStream.flush()
socket.close()
return true
} catch (e: Exception) {
// Response couldn't be delivered — permission effectively expired.
// Emit PermissionExpired so React UI clears the stale approval card.
Expand All @@ -197,12 +262,15 @@ class EventBridge(private val socketName: String) {
sessionId = "", // ManagedSession uses its own ID for broadcast
hookEventName = "PermissionExpired",
requestId = requestId,
reason = "delivery-failed",
))
return false
}
}

/** Close a held socket without sending a response (cross-path cleanup). */
fun closeSocket(requestId: String) {
holdJobs.remove(requestId)?.cancel()
val socket = pendingSockets.remove(requestId) ?: return
try { socket.close() } catch (_: Exception) {}
}
Expand All @@ -219,6 +287,10 @@ class EventBridge(private val socketName: String) {
fun hasPendingPermission(): Boolean = pendingSockets.isNotEmpty()

fun stop() {
// Cancel any outstanding tier-1 hold timers so they don't fire (and
// try to write to a socket we're about to close) after teardown.
holdJobs.values.forEach { it.cancel() }
holdJobs.clear()
// Close all pending sockets
for ((_, socket) in pendingSockets) {
try { socket.close() } catch (_: Exception) {}
Expand Down
11 changes: 7 additions & 4 deletions app/src/main/kotlin/com/youcoded/app/parser/HookEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,17 @@ sealed class HookEvent {
val requestId: String,
) : HookEvent()

/** Emitted when a held PermissionRequest socket closes before a response
* was sent — e.g., hook-relay-blocking.js timed out (120s) or Claude Code
* killed the hook process. React uses this to clear stale approval cards.
* Desktop equivalent: hook-relay.ts socket.on('close') → 'permission-expired'. */
/** Emitted when a held PermissionRequest ends without a delivered user
* decision. reason discriminates (2026-07-30 spec §2): "app-timeout"
* (our own 2h hold fired — a deny WAS delivered), "delivery-failed"
* (respond() write threw), "hook-closed" (relay died / CC killed the
* hook — the terminal menu may still be live; React retains the card),
* or null (legacy producers; React resolves). */
data class PermissionExpired(
override val sessionId: String,
override val hookEventName: String,
val requestId: String,
val reason: String? = null,
) : HookEvent()

companion object {
Expand Down
81 changes: 50 additions & 31 deletions app/src/main/kotlin/com/youcoded/app/runtime/Bootstrap.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,53 @@ class Bootstrap(internal val context: Context) {
// Last Claude Code release shipping cli.js. See the comment on
// isFullySetup and installClaudeCode() for why this is pinned.
private const val PINNED_CLAUDE_CODE_VERSION = "2.1.112"

/** Tier-3 CC hook timeout (3h) — 30m above the relay asset's 2h30m so CC
* never kills the hook first (no decision = AskUserQuestion wedges
* forever, spec §1). Pinned by desktop/tests/permission-timeout-margins. */
const val PERMISSION_HOOK_TIMEOUT_SECONDS = 10800

/** Ensure the PermissionRequest blocking-relay hook entry exists AND
* carries the current command + timeout. WHY: earlier versions only
* appended when missing, so every existing install kept timeout 300
* forever — and the relay asset DOES redeploy on every launch, so a
* relay-only change would put relay-2h30m against CC-300s: CC kills the
* hook with no decision and AskUserQuestion wedges permanently
* (2026-07-30 spec §Constraints). Mirrors desktop install-hooks.js
* find-and-replace semantics. */
fun ensurePermissionRequestHook(
hooksObj: org.json.JSONObject,
blockingHookCommand: String,
timeoutSeconds: Int,
) {
val prEvent = "PermissionRequest"
val prArray = hooksObj.optJSONArray(prEvent) ?: org.json.JSONArray()
var updated = false
for (i in 0 until prArray.length()) {
val hooks = prArray.optJSONObject(i)?.optJSONArray("hooks") ?: continue
for (j in 0 until hooks.length()) {
val h = hooks.optJSONObject(j)
if (h?.optString("command")?.contains("hook-relay-blocking.js") == true) {
h.put("command", blockingHookCommand)
h.put("timeout", timeoutSeconds)
updated = true
}
}
}
if (!updated) {
val hookEntry = org.json.JSONObject()
hookEntry.put("matcher", ".*")
val hooksList = org.json.JSONArray()
val hookDef = org.json.JSONObject()
hookDef.put("type", "command")
hookDef.put("command", blockingHookCommand)
hookDef.put("timeout", timeoutSeconds)
hooksList.put(hookDef)
hookEntry.put("hooks", hooksList)
prArray.put(hookEntry)
}
hooksObj.put(prEvent, prArray)
}
}

val usrDir: File get() = File(context.filesDir, "usr")
Expand Down Expand Up @@ -986,37 +1033,9 @@ class Bootstrap(internal val context: Context) {
hooksObj.put(event, eventArray)
}

// Register PermissionRequest with blocking relay (long timeout for user approval)
val prEvent = "PermissionRequest"
val prArray = hooksObj.optJSONArray(prEvent) ?: org.json.JSONArray()
var prRegistered = false
for (i in 0 until prArray.length()) {
val entry = prArray.optJSONObject(i)
val hooks = entry?.optJSONArray("hooks")
if (hooks != null) {
for (j in 0 until hooks.length()) {
val h = hooks.optJSONObject(j)
if (h?.optString("command")?.contains("hook-relay-blocking.js") == true) {
prRegistered = true
break
}
}
}
if (prRegistered) break
}
if (!prRegistered) {
val hookEntry = org.json.JSONObject()
hookEntry.put("matcher", ".*")
val hooksList = org.json.JSONArray()
val hookDef = org.json.JSONObject()
hookDef.put("type", "command")
hookDef.put("command", blockingHookCommand)
hookDef.put("timeout", 300)
hooksList.put(hookDef)
hookEntry.put("hooks", hooksList)
prArray.put(hookEntry)
}
hooksObj.put(prEvent, prArray)
// Register PermissionRequest with blocking relay (tier-3 CC timeout, see
// PERMISSION_HOOK_TIMEOUT_SECONDS doc comment for the margin rationale)
ensurePermissionRequestHook(hooksObj, blockingHookCommand, PERMISSION_HOOK_TIMEOUT_SECONDS)

// Auto-title hook: always deploy the bundled asset. Post-decomposition,
// title-update.sh is app-owned (not a toolkit hook) on both platforms —
Expand Down
12 changes: 8 additions & 4 deletions app/src/main/kotlin/com/youcoded/app/runtime/ManagedSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,17 @@ class ManagedSession(
))
}
is HookEvent.PermissionExpired -> {
// Socket closed before user responded — relay timed out
// or Claude Code killed the hook. Clear the stale approval
// card in React UI. Desktop equivalent: main.ts
// hookRelay.on('permission-expired') handler.
// Ended without a delivered decision (app hold fired,
// respond() write failed, or the relay/hook died).
// reason discriminates which — React uses it to decide
// whether to retain or resolve the stale approval card.
// Routing keys on requestId / this session's own `id`,
// NEVER event.sessionId (EventBridge's write-failure
// path emits that as "" — see EventBridge.kt).
server.broadcast(HookSerializer.permissionExpired(
sessionId = id,
requestId = event.requestId,
reason = event.reason,
))
}
is HookEvent.Notification -> {
Expand Down
11 changes: 11 additions & 0 deletions app/src/test/kotlin/com/youcoded/app/bridge/HookSerializerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ class HookSerializerTest {
assertFalse("Should not have 'requestId'", inner.has("requestId"))
}

@Test
fun `permissionExpired carries _reason when present and omits it when null`() {
val with = HookSerializer.permissionExpired("s1", "r1", "hook-closed")
val inner = with.getJSONObject("payload").getJSONObject("payload")
assertEquals("hook-closed", inner.getString("_reason"))

val without = HookSerializer.permissionExpired("s1", "r1", null)
val innerNone = without.getJSONObject("payload").getJSONObject("payload")
assertFalse(innerNone.has("_reason"))
}

// ── notification ─────────────────────────────────────────────────────────

@Test
Expand Down
48 changes: 48 additions & 0 deletions app/src/test/kotlin/com/youcoded/app/runtime/BootstrapHooksTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.youcoded.app.runtime

import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Test

/** Guards the 2026-07-30 spec §Constraints inversion: an install that already
* has the hook must still receive a changed timeout on the next launch. */
class BootstrapHooksTest {

private fun existingHooks(timeout: Int): JSONObject {
val h = JSONObject().put("type", "command")
.put("command", "node /old/path/hook-relay-blocking.js").put("timeout", timeout)
val entry = JSONObject().put("matcher", ".*")
.put("hooks", JSONArray().put(h))
return JSONObject().put("PermissionRequest", JSONArray().put(entry))
}

@Test
fun `overwrites timeout and command on an existing entry`() {
val hooksObj = existingHooks(300)
Bootstrap.ensurePermissionRequestHook(hooksObj, "node /new/path/hook-relay-blocking.js", 10800)
val h = hooksObj.getJSONArray("PermissionRequest")
.getJSONObject(0).getJSONArray("hooks").getJSONObject(0)
assertEquals(10800, h.getInt("timeout"))
assertEquals("node /new/path/hook-relay-blocking.js", h.getString("command"))
}

@Test
fun `appends a new entry when none exists`() {
val hooksObj = JSONObject()
Bootstrap.ensurePermissionRequestHook(hooksObj, "node /p/hook-relay-blocking.js", 10800)
val arr = hooksObj.getJSONArray("PermissionRequest")
assertEquals(1, arr.length())
val h = arr.getJSONObject(0).getJSONArray("hooks").getJSONObject(0)
assertEquals(10800, h.getInt("timeout"))
assertEquals("command", h.getString("type"))
}

@Test
fun `does not duplicate on repeat runs`() {
val hooksObj = existingHooks(300)
Bootstrap.ensurePermissionRequestHook(hooksObj, "node /p/hook-relay-blocking.js", 10800)
Bootstrap.ensurePermissionRequestHook(hooksObj, "node /p/hook-relay-blocking.js", 10800)
assertEquals(1, hooksObj.getJSONArray("PermissionRequest").length())
}
}
Loading