@agentdeck/bridge 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ask-gate.d.ts +113 -0
- package/dist/ask-gate.d.ts.map +1 -0
- package/dist/ask-gate.js +110 -0
- package/dist/ask-gate.js.map +1 -0
- package/dist/auth.d.ts +8 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +15 -0
- package/dist/auth.js.map +1 -1
- package/dist/awaiting-overlay.d.ts +54 -6
- package/dist/awaiting-overlay.d.ts.map +1 -1
- package/dist/awaiting-overlay.js +109 -26
- package/dist/awaiting-overlay.js.map +1 -1
- package/dist/broadcast.d.ts +1 -1
- package/dist/broadcast.d.ts.map +1 -1
- package/dist/broadcast.js +10 -7
- package/dist/broadcast.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +74 -4
- package/dist/cli.js.map +1 -1
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +239 -20
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/daemon.js.map +1 -1
- package/dist/http-auth-gate.d.ts +40 -0
- package/dist/http-auth-gate.d.ts.map +1 -0
- package/dist/http-auth-gate.js +46 -0
- package/dist/http-auth-gate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -9
- package/dist/index.js.map +1 -1
- package/dist/mdns.d.ts +7 -1
- package/dist/mdns.d.ts.map +1 -1
- package/dist/mdns.js +7 -3
- package/dist/mdns.js.map +1 -1
- package/dist/modules/broadcast-module.d.ts.map +1 -1
- package/dist/modules/broadcast-module.js +3 -1
- package/dist/modules/broadcast-module.js.map +1 -1
- package/dist/modules/mdns-module.d.ts.map +1 -1
- package/dist/modules/mdns-module.js +3 -1
- package/dist/modules/mdns-module.js.map +1 -1
- package/dist/observed-inject.d.ts +21 -5
- package/dist/observed-inject.d.ts.map +1 -1
- package/dist/observed-inject.js +85 -22
- package/dist/observed-inject.js.map +1 -1
- package/dist/observed-steering.d.ts +38 -0
- package/dist/observed-steering.d.ts.map +1 -1
- package/dist/observed-steering.js +53 -0
- package/dist/observed-steering.js.map +1 -1
- package/dist/passive-observer.d.ts +9 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +90 -5
- package/dist/passive-observer.js.map +1 -1
- package/dist/permission-resolver.d.ts +11 -0
- package/dist/permission-resolver.d.ts.map +1 -1
- package/dist/permission-resolver.js +23 -3
- package/dist/permission-resolver.js.map +1 -1
- package/dist/session-registry.d.ts +23 -2
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +56 -5
- package/dist/session-registry.js.map +1 -1
- package/package.json +3 -3
package/dist/daemon-server.js
CHANGED
|
@@ -25,9 +25,10 @@ import { SessionFocusRelay } from './session-focus-relay.js';
|
|
|
25
25
|
import { SubagentTimelineTracker } from './subagent-timeline.js';
|
|
26
26
|
import { getRemoteSession, getRemoteSender, listRemoteEnriched, } from './remote-sessions.js';
|
|
27
27
|
import { createPushChannelHandler } from './session-push-channel.js';
|
|
28
|
-
import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
28
|
+
import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, advanceAskUserQuestionOverlay, getAskUserQuestionAnswers, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
|
|
29
|
+
import { askEchoMatches, askGateDecision, askPressVerdict } from './ask-gate.js';
|
|
30
|
+
import { registerPending, resolvePending, resolvePendingWithReason, sweepStalePending, drainAllPending, isPendingRequest, } from './permission-resolver.js';
|
|
31
|
+
import { shouldHoldPreToolUse, beginAskGate, gateReleased, buildGateQuestion, buildAskAnswerReason, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, } from './observed-steering.js';
|
|
31
32
|
import { notePermissionPromptShown, noteToolEnd, steeringSnapshot, } from './observed-steering.js';
|
|
32
33
|
import { resolveSessionIdPrefix } from './session-id-resolve.js';
|
|
33
34
|
import { injectObservedSelection, injectObservedText } from './observed-inject.js';
|
|
@@ -46,6 +47,15 @@ const OBSERVED_APPROVAL_ENABLED = process.env.AGENTDECK_OBSERVED_APPROVAL !== '0
|
|
|
46
47
|
* call to Claude's own permission flow. Must stay well under the hook curl's
|
|
47
48
|
* --max-time 60 so the release reaches Claude before curl gives up. */
|
|
48
49
|
const OBSERVED_APPROVAL_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGENTDECK_APPROVAL_HOLD_MS) || 25_000));
|
|
50
|
+
/** AskUserQuestion ask-gate (PreToolUse hold so a device can answer the
|
|
51
|
+
* question). Only ever engaged when this daemon has no way to type the answer
|
|
52
|
+
* into the session's terminal, so it costs the terminal user nothing whenever
|
|
53
|
+
* injection is available. Kill switch for field issues. */
|
|
54
|
+
const ASK_GATE_ENABLED = process.env.AGENTDECK_ASK_GATE !== '0';
|
|
55
|
+
/** Hold budget for an ask-gate. Longer than the permission gate because the
|
|
56
|
+
* user may answer several question groups in one call, and still under the
|
|
57
|
+
* hook curl's --max-time 60 so the release reaches Claude. */
|
|
58
|
+
const ASK_GATE_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGENTDECK_ASK_GATE_HOLD_MS) || 45_000));
|
|
49
59
|
/** Inactivity after which an unclosed APME run is treated as abandoned and
|
|
50
60
|
* finalized. Deliberately generous: an orphan stays reapable forever, so
|
|
51
61
|
* closing it late costs nothing, while reaping a live run corrupts the unit
|
|
@@ -55,9 +65,10 @@ const OBSERVED_APPROVAL_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(proces
|
|
|
55
65
|
const APME_ABANDONED_RUN_STALE_SEC = Math.max(600, Number(process.env.AGENTDECK_APME_ABANDON_SEC) || 7200);
|
|
56
66
|
import { VoiceManager } from './voice.js';
|
|
57
67
|
import { VoiceAssistantManager } from './voice-assistant.js';
|
|
58
|
-
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
|
|
68
|
+
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, waitForPortBindable, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
|
|
59
69
|
import { fetchUsageFromApi, hasOAuthToken, resetConsecutiveFailures } from './usage-api.js';
|
|
60
70
|
import { isLocalConnection, validateToken } from './auth.js';
|
|
71
|
+
import { buildPublicHealth, gateHttpRequest, isAuthorizedHttpRequest } from './http-auth-gate.js';
|
|
61
72
|
import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
|
|
62
73
|
import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
|
|
63
74
|
import { handlePixooWake } from './pixoo/pixoo-client.js';
|
|
@@ -893,6 +904,10 @@ export async function startDaemon(opts) {
|
|
|
893
904
|
log(`[agentdeck] Swift daemon detected on port ${probePort}. Requesting shutdown to take over...`);
|
|
894
905
|
await requestDaemonShutdown(probePort);
|
|
895
906
|
await waitForDaemonExit(probePort);
|
|
907
|
+
// …and until the socket is actually released. NWListener.cancel()
|
|
908
|
+
// returns before the port is free, so "stopped answering" alone would
|
|
909
|
+
// let the bind below land on a fallback port.
|
|
910
|
+
await waitForPortBindable(probePort);
|
|
896
911
|
removeDaemonInfo();
|
|
897
912
|
}
|
|
898
913
|
else {
|
|
@@ -937,6 +952,7 @@ export async function startDaemon(opts) {
|
|
|
937
952
|
log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
|
|
938
953
|
await requestDaemonShutdown(requestedPort);
|
|
939
954
|
await waitForDaemonExit(requestedPort);
|
|
955
|
+
await waitForPortBindable(requestedPort);
|
|
940
956
|
}
|
|
941
957
|
else {
|
|
942
958
|
// Daemon alive but not in our registry — race condition or stale state
|
|
@@ -989,6 +1005,33 @@ export async function startDaemon(opts) {
|
|
|
989
1005
|
// Codex sessions known only from `codex_*` hooks — the backstop for when the
|
|
990
1006
|
// process scan can't see one (lsof timeout, no rollout held open).
|
|
991
1007
|
const hookCodexSessions = new HookCodexSessions();
|
|
1008
|
+
// Declared before the HTTP server: the PreToolUse route reads it to decide
|
|
1009
|
+
// whether this daemon can type into a session's terminal, and hooks start
|
|
1010
|
+
// arriving the moment the port binds — several hundred milliseconds before
|
|
1011
|
+
// the rest of startup finishes. A later `const` would leave that window
|
|
1012
|
+
// throwing on the temporal dead zone.
|
|
1013
|
+
const passiveSessionObserver = new PassiveSessionObserver();
|
|
1014
|
+
/** Observed Claude sessions whose AskUserQuestion PreToolUse is held open for
|
|
1015
|
+
* a device answer, keyed by the bare Claude session UUID. Its presence is
|
|
1016
|
+
* what makes the session's options pressable (`liveAnswerable`) and what
|
|
1017
|
+
* routes a `select_option` press to the gate instead of terminal injection. */
|
|
1018
|
+
const pendingAskGateBySession = new Map();
|
|
1019
|
+
/** Per session UUID: the timestamp until which `select_option` presses are
|
|
1020
|
+
* swallowed because a terminal injection is typing (or has just finished).
|
|
1021
|
+
* A second press landing mid-injection would aim its arrow keys at a picker
|
|
1022
|
+
* that is about to advance to the next question, selecting the wrong option
|
|
1023
|
+
* there — so the guard spans the keystrokes plus a short settle window. */
|
|
1024
|
+
const injectionGuardUntil = new Map();
|
|
1025
|
+
/** Per session UUID: when the next ask-gate press will be accepted. An
|
|
1026
|
+
* answer is irreversible (it resolves the agent's hook), and a grouped
|
|
1027
|
+
* prompt advances on each one, so two frames from a bouncing key would
|
|
1028
|
+
* answer two different questions with the same index. */
|
|
1029
|
+
const askPressGuardUntil = new Map();
|
|
1030
|
+
const ASK_PRESS_DEBOUNCE_MS = 400;
|
|
1031
|
+
/** Upper bound for one injection attempt (host ladder + paced keystrokes). */
|
|
1032
|
+
const INJECTION_GUARD_MAX_MS = 60_000;
|
|
1033
|
+
/** Settle window after the keys land, covering the TUI's own redraw. */
|
|
1034
|
+
const INJECTION_SETTLE_MS = 1_200;
|
|
992
1035
|
// Declare early — HTTP /health handler references this in its closure.
|
|
993
1036
|
// Must be declared before the HTTP server so it's initialized (not in TDZ)
|
|
994
1037
|
// when the first /health request arrives.
|
|
@@ -1089,6 +1132,32 @@ export async function startDaemon(opts) {
|
|
|
1089
1132
|
let subagentTimeline = null;
|
|
1090
1133
|
// ===== HTTP server =====
|
|
1091
1134
|
const httpServer = createServer((req, res) => {
|
|
1135
|
+
// LAN default-deny (issue #145): a request that is neither same-machine
|
|
1136
|
+
// nor token-bearing reaches exactly one route — a minimal GET /health
|
|
1137
|
+
// with no pairingToken, no module/device inventory, no session state.
|
|
1138
|
+
// Everything else 401s here, before any route dispatch. The per-route
|
|
1139
|
+
// token checks further down remain as defense in depth.
|
|
1140
|
+
{
|
|
1141
|
+
const gatePathname = (() => {
|
|
1142
|
+
try {
|
|
1143
|
+
return new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
1144
|
+
}
|
|
1145
|
+
catch {
|
|
1146
|
+
return '/';
|
|
1147
|
+
}
|
|
1148
|
+
})();
|
|
1149
|
+
const decision = gateHttpRequest(req.method ?? 'GET', gatePathname, isAuthorizedHttpRequest(req));
|
|
1150
|
+
if (decision === 'public-health') {
|
|
1151
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1152
|
+
res.end(JSON.stringify(buildPublicHealth(port)));
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (decision === 'deny') {
|
|
1156
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1157
|
+
res.end(JSON.stringify({ error: 'Unauthorized — pairing token required for LAN access' }));
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1092
1161
|
// APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
|
|
1093
1162
|
if ((req.url ?? '').startsWith('/apme')) {
|
|
1094
1163
|
const ip = req.socket.remoteAddress ?? '';
|
|
@@ -1112,7 +1181,10 @@ export async function startDaemon(opts) {
|
|
|
1112
1181
|
}
|
|
1113
1182
|
const parsedUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
1114
1183
|
const pathname = parsedUrl.pathname;
|
|
1115
|
-
//
|
|
1184
|
+
// Full health — only reachable same-machine or with a valid pairing token
|
|
1185
|
+
// (the LAN gate above serves unauthenticated peers buildPublicHealth
|
|
1186
|
+
// instead). pairingToken stays here for local consumers: sibling bridges,
|
|
1187
|
+
// the TUI, and the macOS companion's file-less fallback.
|
|
1116
1188
|
if (req.method === 'GET' && pathname === '/health') {
|
|
1117
1189
|
const snap = core.stateMachine.getSnapshot();
|
|
1118
1190
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1415,8 +1487,8 @@ export async function startDaemon(opts) {
|
|
|
1415
1487
|
}
|
|
1416
1488
|
// ===== Card Feed pull sync (M6) — wake-sync-sleep battery clients =====
|
|
1417
1489
|
// Auth mirrors /apme: local connections are free; anything else needs the
|
|
1418
|
-
// pairing token (?token=). Devices hold it from provisioning
|
|
1419
|
-
//
|
|
1490
|
+
// pairing token (?token=). Devices hold it from provisioning — since
|
|
1491
|
+
// issue #145 the daemon no longer hands it to unauthenticated LAN peers.
|
|
1420
1492
|
// ===== Glance Frame (M8) — daemon-rendered pixels of the glance =====
|
|
1421
1493
|
// The device-side glance renderer is the offline fallback; this is the
|
|
1422
1494
|
// rich face. `?format=png` returns the exact dithered panel pixels, so a
|
|
@@ -1789,6 +1861,9 @@ export async function startDaemon(opts) {
|
|
|
1789
1861
|
}
|
|
1790
1862
|
else if (mapped === 'session_end') {
|
|
1791
1863
|
clearSteeringSession(claudeSid);
|
|
1864
|
+
pendingAskGateBySession.delete(claudeSid);
|
|
1865
|
+
injectionGuardUntil.delete(claudeSid);
|
|
1866
|
+
askPressGuardUntil.delete(claudeSid);
|
|
1792
1867
|
}
|
|
1793
1868
|
const currentOverlay = getAwaitingOverlay(claudeSid);
|
|
1794
1869
|
let cleared = false;
|
|
@@ -2118,7 +2193,66 @@ export async function startDaemon(opts) {
|
|
|
2118
2193
|
core.broadcastSessionsList().catch(() => { });
|
|
2119
2194
|
return;
|
|
2120
2195
|
}
|
|
2121
|
-
// 2.
|
|
2196
|
+
// 2. AskUserQuestion ask-gate: the structured overlay was set from
|
|
2197
|
+
// this same hook above, so devices are already showing the real
|
|
2198
|
+
// choices. Hold the call so a device press can answer it — the
|
|
2199
|
+
// only delivery channel a sandboxed daemon has, since it cannot
|
|
2200
|
+
// type into the user's terminal. Skipped whenever injection IS
|
|
2201
|
+
// available: answering the live picker costs the person sitting at
|
|
2202
|
+
// that terminal nothing, whereas a hold makes them wait for it to
|
|
2203
|
+
// appear. Timeout releases with an empty body, so an unanswered
|
|
2204
|
+
// question just shows up in the terminal as usual.
|
|
2205
|
+
if (claudeSid && toolName === 'AskUserQuestion') {
|
|
2206
|
+
const askOverlay = getAwaitingOverlay(claudeSid);
|
|
2207
|
+
if (askOverlay?.kind === 'option') {
|
|
2208
|
+
const obs = passiveSessionObserver.collect([])
|
|
2209
|
+
.find((s) => s.id === `observed:claude:${claudeSid}`);
|
|
2210
|
+
const worthHolding = askGateDecision({
|
|
2211
|
+
enabled: ASK_GATE_ENABLED,
|
|
2212
|
+
clientCount: core.wsServer.getClientCount(),
|
|
2213
|
+
observed: obs,
|
|
2214
|
+
});
|
|
2215
|
+
const gate = worthHolding.hold
|
|
2216
|
+
? beginAskGate({
|
|
2217
|
+
sessionId: claudeSid,
|
|
2218
|
+
clientCount: core.wsServer.getClientCount(),
|
|
2219
|
+
enabled: ASK_GATE_ENABLED,
|
|
2220
|
+
})
|
|
2221
|
+
: { hold: false, requestId: undefined, reason: worthHolding.reason };
|
|
2222
|
+
if (gate.hold && gate.requestId) {
|
|
2223
|
+
const requestId = gate.requestId;
|
|
2224
|
+
const askToolUseId = typeof json.tool_use_id === 'string' ? json.tool_use_id : undefined;
|
|
2225
|
+
debug('daemon', `AskUserQuestion gate held: req=${requestId} sid=${claudeSid.slice(0, 8)}`);
|
|
2226
|
+
pendingAskGateBySession.set(claudeSid, { requestId, toolUseId: askToolUseId });
|
|
2227
|
+
core.broadcastSessionsList().catch(() => { }); // liveAnswerable → true
|
|
2228
|
+
registerPending(requestId, res, {
|
|
2229
|
+
sessionId: claudeSid,
|
|
2230
|
+
tool: toolName,
|
|
2231
|
+
timeoutMs: ASK_GATE_HOLD_MS,
|
|
2232
|
+
onResolved: (decision) => {
|
|
2233
|
+
pendingAskGateBySession.delete(claudeSid);
|
|
2234
|
+
// `undecided: false` always: an unanswered question says
|
|
2235
|
+
// nothing about what Claude auto-approves, so it must not
|
|
2236
|
+
// feed the auto-approval learner.
|
|
2237
|
+
gateReleased(claudeSid, requestId, {
|
|
2238
|
+
undecided: false, tool: toolName, toolInput,
|
|
2239
|
+
});
|
|
2240
|
+
// Answered → the tool call is denied and never runs, so no
|
|
2241
|
+
// PostToolUse will ever arrive to clear the overlay; clear
|
|
2242
|
+
// it here. Timed out → the picker is now on the user's own
|
|
2243
|
+
// screen, so the overlay stays as a display-only prompt and
|
|
2244
|
+
// liveAnswerable falls back to false.
|
|
2245
|
+
if (decision === 'deny')
|
|
2246
|
+
clearAskUserQuestionOverlay(claudeSid, askToolUseId);
|
|
2247
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2248
|
+
},
|
|
2249
|
+
});
|
|
2250
|
+
return; // response held open — resolved by device / timeout
|
|
2251
|
+
}
|
|
2252
|
+
debug('daemon', `AskUserQuestion not held: ${gate.reason} (${claudeSid.slice(0, 8)})`);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
// 3. Device-approval gate: hold the response for a device decision,
|
|
2122
2256
|
// but ONLY for calls the precision guards say Claude would
|
|
2123
2257
|
// genuinely prompt for (see shouldHoldPreToolUse — mode gate,
|
|
2124
2258
|
// never-prompt/prompt-prone sets, allowlist prediction, learned
|
|
@@ -2401,6 +2535,12 @@ export async function startDaemon(opts) {
|
|
|
2401
2535
|
if (!socket.destroyed)
|
|
2402
2536
|
socket.destroy();
|
|
2403
2537
|
});
|
|
2538
|
+
// All-interfaces bind is deliberate — companion apps, ESP32 boards, and
|
|
2539
|
+
// pull-sync e-ink clients live on the LAN — and the security boundary is the
|
|
2540
|
+
// pairing token (http-auth-gate.ts default-denies unauthenticated LAN
|
|
2541
|
+
// requests; the WS server has always token-gated non-local peers). Users
|
|
2542
|
+
// with no LAN devices can opt into a loopback-only bind (issue #145).
|
|
2543
|
+
const bindHost = process.env.AGENTDECK_LOOPBACK_ONLY === '1' ? '127.0.0.1' : '0.0.0.0';
|
|
2404
2544
|
await new Promise((resolve, reject) => {
|
|
2405
2545
|
httpServer.on('error', (err) => {
|
|
2406
2546
|
if (err.code === 'EADDRINUSE') {
|
|
@@ -2411,7 +2551,7 @@ export async function startDaemon(opts) {
|
|
|
2411
2551
|
reject(err);
|
|
2412
2552
|
}
|
|
2413
2553
|
});
|
|
2414
|
-
httpServer.listen(port,
|
|
2554
|
+
httpServer.listen(port, bindHost, () => resolve());
|
|
2415
2555
|
}).catch(async (err) => {
|
|
2416
2556
|
// Handle race condition: port became unavailable after our pre-bind probe.
|
|
2417
2557
|
// This is the concurrent-start case (e.g. two logon-trigger fires landing
|
|
@@ -2439,13 +2579,19 @@ export async function startDaemon(opts) {
|
|
|
2439
2579
|
log(`[agentdeck] Port ${requestedPort} grabbed by ${occupant?.mode ?? 'a non-daemon'}, retrying on ${port}...`);
|
|
2440
2580
|
await new Promise((resolve, reject) => {
|
|
2441
2581
|
httpServer.on('error', (e) => reject(e));
|
|
2442
|
-
httpServer.listen(port,
|
|
2582
|
+
httpServer.listen(port, bindHost, () => resolve());
|
|
2443
2583
|
});
|
|
2444
2584
|
}
|
|
2445
2585
|
else {
|
|
2446
2586
|
throw err;
|
|
2447
2587
|
}
|
|
2448
2588
|
});
|
|
2589
|
+
if (bindHost === '127.0.0.1') {
|
|
2590
|
+
log(`[agentdeck] AGENTDECK_LOOPBACK_ONLY=1 — daemon bound to 127.0.0.1:${port}; LAN devices (companion apps, ESP32/WiFi boards) cannot connect.`);
|
|
2591
|
+
}
|
|
2592
|
+
else {
|
|
2593
|
+
log(`[agentdeck] Daemon listening on all interfaces (:${port}) — LAN requests require the pairing token (~/.agentdeck/auth-token). Set AGENTDECK_LOOPBACK_ONLY=1 for a loopback-only bind.`);
|
|
2594
|
+
}
|
|
2449
2595
|
// Write daemon.json for client discovery (must be after successful bind).
|
|
2450
2596
|
// Keep the original startedAt stable when the self-heal timer rewrites it.
|
|
2451
2597
|
const daemonInfo = { port, pid: process.pid, startedAt: new Date().toISOString() };
|
|
@@ -2863,7 +3009,7 @@ export async function startDaemon(opts) {
|
|
|
2863
3009
|
}
|
|
2864
3010
|
}
|
|
2865
3011
|
log(`[agentdeck] WebSocket server ready on port ${port}`);
|
|
2866
|
-
log(
|
|
3012
|
+
log('[agentdeck] Pairing credential ready; run "agentdeck qr" to pair a device.');
|
|
2867
3013
|
// Initialize APME store + collector so the daemon can serve /apme/* HTTP
|
|
2868
3014
|
// routes. `setApme` on core is gated against the `daemon` meta-session so
|
|
2869
3015
|
// register/deregister won't open a bogus run. Session bridges opening their
|
|
@@ -2908,7 +3054,6 @@ export async function startDaemon(opts) {
|
|
|
2908
3054
|
}
|
|
2909
3055
|
// Register session
|
|
2910
3056
|
core.registerSession('daemon');
|
|
2911
|
-
const passiveSessionObserver = new PassiveSessionObserver();
|
|
2912
3057
|
// The observer scans in the background now (collect() returns the cache
|
|
2913
3058
|
// immediately). When a scan lands fresh observations, push them out via
|
|
2914
3059
|
// the debounced broadcast so clients don't wait for the next 10 s poll.
|
|
@@ -2939,12 +3084,15 @@ export async function startDaemon(opts) {
|
|
|
2939
3084
|
// "stopping at next tool" / queued-directive badges from these.
|
|
2940
3085
|
if (!s.id.startsWith('observed:claude:'))
|
|
2941
3086
|
return { ...s, liveAnswerable: false };
|
|
2942
|
-
//
|
|
2943
|
-
//
|
|
2944
|
-
//
|
|
2945
|
-
// AskUserQuestion
|
|
2946
|
-
|
|
2947
|
-
|
|
3087
|
+
// Will a device press on this session's options reach the agent? Two
|
|
3088
|
+
// rungs qualify: this daemon can type into the session's live prompt
|
|
3089
|
+
// (a reachable terminal/app host), or it is holding the session's
|
|
3090
|
+
// AskUserQuestion open and will answer it with the device's choice.
|
|
3091
|
+
// Sent in both polarities — decks read it to decide whether an option
|
|
3092
|
+
// is a button or an inert mirror of the terminal.
|
|
3093
|
+
const uuid = s.id.slice('observed:claude:'.length);
|
|
3094
|
+
const liveAnswerable = Boolean(s.tty || s.appName) || pendingAskGateBySession.has(uuid);
|
|
3095
|
+
const snap = steeringSnapshot(uuid);
|
|
2948
3096
|
return {
|
|
2949
3097
|
...s,
|
|
2950
3098
|
liveAnswerable,
|
|
@@ -3468,6 +3616,51 @@ export async function startDaemon(opts) {
|
|
|
3468
3616
|
}
|
|
3469
3617
|
if (type === 'respond' || type === 'select_option') {
|
|
3470
3618
|
const ov = getAwaitingOverlay(uuid);
|
|
3619
|
+
// Stale-press guard. A multi-group AskUserQuestion moves to the next
|
|
3620
|
+
// question the instant one is answered, so an index pressed against the
|
|
3621
|
+
// question the device was still displaying would select the wrong option
|
|
3622
|
+
// in the new list. Devices that echo what they showed get validated;
|
|
3623
|
+
// re-broadcasting re-syncs them to the live question.
|
|
3624
|
+
const echo = typeof command.question === 'string' ? command.question : undefined;
|
|
3625
|
+
if (ov?.kind === 'option' && echo && ov.question && !askEchoMatches(echo, ov.question)) {
|
|
3626
|
+
debug('daemon', `observed ${type} dropped: answers a stale question (${uuid.slice(0, 8)})`);
|
|
3627
|
+
core.broadcastSessionsList().catch(() => { });
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
// Held AskUserQuestion gate — this daemon owns the prompt (the picker
|
|
3631
|
+
// never reached the user's terminal). Record the answer, show the next
|
|
3632
|
+
// group, and resolve the hook once every group has one. Every rejection
|
|
3633
|
+
// below is a press that would otherwise commit an answer the user did
|
|
3634
|
+
// not give, so `askPressVerdict` is deliberately strict.
|
|
3635
|
+
const askGate = pendingAskGateBySession.get(uuid);
|
|
3636
|
+
if (askGate) {
|
|
3637
|
+
const now = Date.now();
|
|
3638
|
+
if (now < (askPressGuardUntil.get(uuid) ?? 0)) {
|
|
3639
|
+
debug('daemon', `ask-gate press dropped: too soon after the last one (${uuid.slice(0, 8)})`);
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
const verdict = askPressVerdict({
|
|
3643
|
+
overlay: ov, gateToolUseId: askGate.toolUseId, command,
|
|
3644
|
+
});
|
|
3645
|
+
if (!verdict.ok) {
|
|
3646
|
+
debug('daemon', `ask-gate press dropped: ${verdict.reason} (${uuid.slice(0, 8)})`);
|
|
3647
|
+
if (verdict.resync)
|
|
3648
|
+
core.broadcastSessionsList().catch(() => { });
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
askPressGuardUntil.set(uuid, now + ASK_PRESS_DEBOUNCE_MS);
|
|
3652
|
+
const { label } = verdict;
|
|
3653
|
+
const step = advanceAskUserQuestionOverlay(uuid, label);
|
|
3654
|
+
if (step === 'complete') {
|
|
3655
|
+
resolvePendingWithReason(askGate.requestId, 'deny', buildAskAnswerReason(getAskUserQuestionAnswers(uuid)));
|
|
3656
|
+
debug('daemon', `ask-gate answered (${uuid.slice(0, 8)}): "${label.slice(0, 40)}"`);
|
|
3657
|
+
}
|
|
3658
|
+
else {
|
|
3659
|
+
// Next question group — devices swap to it on this broadcast.
|
|
3660
|
+
core.broadcastSessionsList().catch(() => { });
|
|
3661
|
+
}
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3471
3664
|
if (ov?.requestId) {
|
|
3472
3665
|
const allow = type === 'select_option'
|
|
3473
3666
|
? command.index === 0
|
|
@@ -3481,16 +3674,42 @@ export async function startDaemon(opts) {
|
|
|
3481
3674
|
// terminal isn't reachable the prompt simply stays for the user.
|
|
3482
3675
|
if (type === 'select_option' && typeof command.index === 'number') {
|
|
3483
3676
|
const idx = command.index;
|
|
3677
|
+
const now = Date.now();
|
|
3678
|
+
if (now < (injectionGuardUntil.get(uuid) ?? 0)) {
|
|
3679
|
+
debug('daemon', `observed select_option dropped: injection in flight (${uuid.slice(0, 8)})`);
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
injectionGuardUntil.set(uuid, now + INJECTION_GUARD_MAX_MS);
|
|
3484
3683
|
const obs = passiveSessionObserver.collect([])
|
|
3485
3684
|
.find((s) => s.id === `observed:claude:${uuid}`);
|
|
3486
3685
|
// The option's visible label lets app-hosted prompts press the matching
|
|
3487
3686
|
// native button instead of guessing cursor movement.
|
|
3488
3687
|
const label = ov?.options?.[idx]?.label;
|
|
3489
|
-
|
|
3688
|
+
// A grouped AskUserQuestion does not close on its last answer — it
|
|
3689
|
+
// shows a "Review your answers → Submit answers" confirmation. Nobody
|
|
3690
|
+
// is at that screen to press it, so the last answer carries the extra
|
|
3691
|
+
// Enter. Single-question prompts have no such step.
|
|
3692
|
+
//
|
|
3693
|
+
// The count that decides this is what CLAUDE rendered, not what we
|
|
3694
|
+
// parsed: a group we dropped as malformed is still a tab in the user's
|
|
3695
|
+
// form, so trusting `groups.length` would skip the confirmation on a
|
|
3696
|
+
// form that has one — and the agent would wait on it forever.
|
|
3697
|
+
const groupCount = ov?.rawGroupCount ?? ov?.groups?.length ?? 1;
|
|
3698
|
+
const parsedCount = ov?.groups?.length ?? 1;
|
|
3699
|
+
const confirmSubmit = groupCount > 1 && (ov?.activeGroup ?? 0) === parsedCount - 1;
|
|
3700
|
+
injectObservedSelection({ tty: obs?.tty, appName: obs?.appName, label }, idx, { confirmSubmit }).then((r) => {
|
|
3701
|
+
injectionGuardUntil.set(uuid, Date.now() + INJECTION_SETTLE_MS);
|
|
3490
3702
|
debug('daemon', r.ok
|
|
3491
3703
|
? `observed selection injected via ${r.via} (idx ${idx}, ${uuid.slice(0, 8)})`
|
|
3492
3704
|
: `observed selection NOT injected: ${r.reason} (${uuid.slice(0, 8)})`);
|
|
3493
|
-
|
|
3705
|
+
// The keys landed, so the TUI has moved to the next question group —
|
|
3706
|
+
// move the overlay with it or devices keep offering the answered one.
|
|
3707
|
+
if (r.ok && advanceAskUserQuestionOverlay(uuid, label) !== 'ignored') {
|
|
3708
|
+
core.broadcastSessionsList().catch(() => { });
|
|
3709
|
+
}
|
|
3710
|
+
}).catch(() => {
|
|
3711
|
+
injectionGuardUntil.set(uuid, Date.now() + INJECTION_SETTLE_MS);
|
|
3712
|
+
});
|
|
3494
3713
|
}
|
|
3495
3714
|
return;
|
|
3496
3715
|
}
|