feat(bridge): forward AskUserQuestion to CCR remote
Browse filesRace 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.
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|