chenbhao commited on
Commit
044c1b2
·
1 Parent(s): a8a1ac1

feat(bridge): forward AskUserQuestion to CCR remote

Browse files

Race the standalone question overlay against a bridge control_request so
remote users (claude.ai) can answer. Map per-question answers from the
remote response, degrade a generic allow to the first option, and tear
down either side's prompt whenever one resolves first.

Files changed (1) hide show
  1. src/screens/REPL.tsx +69 -2
src/screens/REPL.tsx CHANGED
@@ -1560,14 +1560,81 @@ export function REPL({
1560
  useState<import('../services/question/questionService.js').QuestionRequest | null>(null)
1561
  useEffect(() => {
1562
  const { questionService } = require('../services/question/questionService.js') as typeof import('../services/question/questionService.js')
1563
- const onAsked = (request: import('../services/question/questionService.js').QuestionRequest) => {
 
 
 
 
 
 
1564
  setQuestionRequest(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1565
  }
 
1566
  questionService.events.on('asked', onAsked)
 
 
1567
  return () => {
1568
  questionService.events.off('asked', onAsked)
 
 
 
 
1569
  }
1570
- }, [])
1571
 
1572
 
1573
  // Track bridge cleanup functions for sandbox permission requests so the
 
1560
  useState<import('../services/question/questionService.js').QuestionRequest | null>(null)
1561
  useEffect(() => {
1562
  const { questionService } = require('../services/question/questionService.js') as typeof import('../services/question/questionService.js')
1563
+ // Bridge cleanup keyed by question request id, so a local reply/reject can
1564
+ // cancel the remote prompt (and vice versa) whichever side answers first.
1565
+ const bridgeCleanups = new Map<string, () => void>()
1566
+
1567
+ const onAsked = (
1568
+ request: import('../services/question/questionService.js').QuestionRequest,
1569
+ ) => {
1570
  setQuestionRequest(request)
1571
+
1572
+ // When the REPL bridge is connected, forward the question to the remote
1573
+ // user (e.g. on claude.ai) as a can_use_tool control_request, racing it
1574
+ // against the local overlay. Mirrors the sandbox permission pattern.
1575
+ if (!feature('BRIDGE_MODE')) return
1576
+ const bridgeCallbacks = store.getState().replBridgePermissionCallbacks
1577
+ if (!bridgeCallbacks) return
1578
+
1579
+ const bridgeRequestId = randomUUID()
1580
+ bridgeCallbacks.sendRequest(
1581
+ bridgeRequestId,
1582
+ 'AskUserQuestion',
1583
+ { questions: request.questions },
1584
+ request.id,
1585
+ request.questions.map(q => q.question).join(' | '),
1586
+ )
1587
+
1588
+ const unsubscribe = bridgeCallbacks.onResponse(bridgeRequestId, response => {
1589
+ bridgeCleanups.delete(request.id)
1590
+ unsubscribe()
1591
+ if (response.behavior === 'allow') {
1592
+ // CCR returns per-question answers when it has a dedicated renderer;
1593
+ // fall back to first option so a generic allow degrades gracefully.
1594
+ const remote = response.updatedInput?.answers
1595
+ const answers = request.questions.map((q, i) => {
1596
+ const provided = Array.isArray(remote) ? remote[i] : undefined
1597
+ if (Array.isArray(provided)) return provided.map(String)
1598
+ if (typeof provided === 'string') return [provided]
1599
+ const first = q.options[0]?.label
1600
+ return first ? [first] : []
1601
+ })
1602
+ questionService.reply({ requestID: request.id, answers })
1603
+ } else {
1604
+ questionService.reject(request.id)
1605
+ }
1606
+ })
1607
+
1608
+ bridgeCleanups.set(request.id, () => {
1609
+ unsubscribe()
1610
+ bridgeCallbacks.cancelRequest(bridgeRequestId)
1611
+ })
1612
+ }
1613
+
1614
+ // Local (or remote) resolution: dismiss the overlay and tear down the
1615
+ // matching remote prompt so it doesn't linger on the other side.
1616
+ const onResolved = (payload: { requestID: string }) => {
1617
+ setQuestionRequest(prev =>
1618
+ prev && prev.id === payload.requestID ? null : prev,
1619
+ )
1620
+ const cleanup = bridgeCleanups.get(payload.requestID)
1621
+ if (cleanup) {
1622
+ bridgeCleanups.delete(payload.requestID)
1623
+ cleanup()
1624
+ }
1625
  }
1626
+
1627
  questionService.events.on('asked', onAsked)
1628
+ questionService.events.on('replied', onResolved)
1629
+ questionService.events.on('rejected', onResolved)
1630
  return () => {
1631
  questionService.events.off('asked', onAsked)
1632
+ questionService.events.off('replied', onResolved)
1633
+ questionService.events.off('rejected', onResolved)
1634
+ for (const cleanup of bridgeCleanups.values()) cleanup()
1635
+ bridgeCleanups.clear()
1636
  }
1637
+ }, [store])
1638
 
1639
 
1640
  // Track bridge cleanup functions for sandbox permission requests so the