@agentdeck/bridge 1.0.20 → 1.0.22
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/adapters/openclaw.d.ts +55 -1
- package/dist/adapters/openclaw.d.ts.map +1 -1
- package/dist/adapters/openclaw.js +278 -45
- package/dist/adapters/openclaw.js.map +1 -1
- package/dist/adb-reverse.d.ts +26 -3
- package/dist/adb-reverse.d.ts.map +1 -1
- package/dist/adb-reverse.js +26 -5
- package/dist/adb-reverse.js.map +1 -1
- package/dist/apme/adapters/openclaw-hook.d.ts +45 -1
- package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
- package/dist/apme/adapters/openclaw-hook.js +84 -4
- package/dist/apme/adapters/openclaw-hook.js.map +1 -1
- package/dist/apme/claude-transcript-reader.d.ts +54 -0
- package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
- package/dist/apme/claude-transcript-reader.js +77 -0
- package/dist/apme/claude-transcript-reader.js.map +1 -1
- package/dist/apme/collector.d.ts +63 -5
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +147 -10
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/store.d.ts +23 -1
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +81 -2
- package/dist/apme/store.js.map +1 -1
- package/dist/apme/types.d.ts +1 -1
- package/dist/apme/types.d.ts.map +1 -1
- package/dist/apme/types.js.map +1 -1
- package/dist/auth.d.ts +37 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +23 -0
- package/dist/auth.js.map +1 -1
- package/dist/ble-sync-spawn.d.ts +27 -0
- package/dist/ble-sync-spawn.d.ts.map +1 -1
- package/dist/ble-sync-spawn.js +32 -0
- package/dist/ble-sync-spawn.js.map +1 -1
- package/dist/bridge-core.d.ts +21 -4
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +52 -23
- package/dist/bridge-core.js.map +1 -1
- package/dist/claude-interrupt-marker.d.ts +31 -0
- package/dist/claude-interrupt-marker.d.ts.map +1 -0
- package/dist/claude-interrupt-marker.js +58 -0
- package/dist/claude-interrupt-marker.js.map +1 -0
- package/dist/claude-turn-watchdog.d.ts +23 -0
- package/dist/claude-turn-watchdog.d.ts.map +1 -1
- package/dist/claude-turn-watchdog.js +30 -4
- package/dist/claude-turn-watchdog.js.map +1 -1
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +411 -115
- package/dist/cli.js.map +1 -1
- package/dist/codex-rate-limits-live.d.ts +58 -9
- package/dist/codex-rate-limits-live.d.ts.map +1 -1
- package/dist/codex-rate-limits-live.js +64 -15
- package/dist/codex-rate-limits-live.js.map +1 -1
- package/dist/codex-rate-limits.d.ts +2 -2
- package/dist/codex-rate-limits.d.ts.map +1 -1
- package/dist/codex-rate-limits.js +34 -16
- package/dist/codex-rate-limits.js.map +1 -1
- package/dist/daemon-port.d.ts +90 -0
- package/dist/daemon-port.d.ts.map +1 -0
- package/dist/daemon-port.js +119 -0
- package/dist/daemon-port.js.map +1 -0
- package/dist/daemon-server.d.ts +60 -3
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +362 -109
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon-settings.d.ts +46 -0
- package/dist/daemon-settings.d.ts.map +1 -0
- package/dist/daemon-settings.js +85 -0
- package/dist/daemon-settings.js.map +1 -0
- package/dist/daemon-takeover.d.ts +75 -0
- package/dist/daemon-takeover.d.ts.map +1 -0
- package/dist/daemon-takeover.js +137 -0
- package/dist/daemon-takeover.js.map +1 -0
- package/dist/daemon.js +1 -1
- package/dist/hook-server.d.ts.map +1 -1
- package/dist/hook-server.js +17 -0
- package/dist/hook-server.js.map +1 -1
- package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -1
- package/dist/idotmatrix/idotmatrix-daemon-sync.js +13 -1
- package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -12
- package/dist/index.js.map +1 -1
- package/dist/kiro-diagnostics.d.ts +90 -0
- package/dist/kiro-diagnostics.d.ts.map +1 -0
- package/dist/kiro-diagnostics.js +278 -0
- package/dist/kiro-diagnostics.js.map +1 -0
- package/dist/kiro-session.d.ts +113 -0
- package/dist/kiro-session.d.ts.map +1 -0
- package/dist/kiro-session.js +688 -0
- package/dist/kiro-session.js.map +1 -0
- package/dist/kiro-timeline-feed.d.ts +45 -0
- package/dist/kiro-timeline-feed.d.ts.map +1 -0
- package/dist/kiro-timeline-feed.js +116 -0
- package/dist/kiro-timeline-feed.js.map +1 -0
- package/dist/kiro-transcript-timeline.d.ts +57 -0
- package/dist/kiro-transcript-timeline.d.ts.map +1 -0
- package/dist/kiro-transcript-timeline.js +183 -0
- package/dist/kiro-transcript-timeline.js.map +1 -0
- package/dist/linux-service.d.ts +2 -1
- package/dist/linux-service.d.ts.map +1 -1
- package/dist/linux-service.js +8 -3
- package/dist/linux-service.js.map +1 -1
- package/dist/mdns-discover.d.ts +27 -0
- package/dist/mdns-discover.d.ts.map +1 -1
- package/dist/mdns-discover.js +60 -7
- package/dist/mdns-discover.js.map +1 -1
- package/dist/mdns.d.ts.map +1 -1
- package/dist/mdns.js +16 -3
- package/dist/mdns.js.map +1 -1
- package/dist/modules/adb-module.d.ts.map +1 -1
- package/dist/modules/adb-module.js +5 -2
- package/dist/modules/adb-module.js.map +1 -1
- package/dist/modules/index.d.ts +2 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -0
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/types.d.ts +34 -9
- package/dist/modules/types.d.ts.map +1 -1
- package/dist/modules/types.js +34 -1
- package/dist/modules/types.js.map +1 -1
- package/dist/network-posture.d.ts +67 -0
- package/dist/network-posture.d.ts.map +1 -0
- package/dist/network-posture.js +100 -0
- package/dist/network-posture.js.map +1 -0
- package/dist/observed-steering.d.ts +14 -0
- package/dist/observed-steering.d.ts.map +1 -1
- package/dist/observed-steering.js +18 -0
- package/dist/observed-steering.js.map +1 -1
- package/dist/observed-turn-watchdogs.d.ts +84 -0
- package/dist/observed-turn-watchdogs.d.ts.map +1 -0
- package/dist/observed-turn-watchdogs.js +134 -0
- package/dist/observed-turn-watchdogs.js.map +1 -0
- package/dist/openclaw-session.d.ts +10 -0
- package/dist/openclaw-session.d.ts.map +1 -1
- package/dist/openclaw-session.js +12 -0
- package/dist/openclaw-session.js.map +1 -1
- package/dist/passive-observer.d.ts +19 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +208 -24
- package/dist/passive-observer.js.map +1 -1
- package/dist/pixoo/micro-glyphs.d.ts +1 -1
- package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
- package/dist/pixoo/micro-glyphs.js +2 -0
- package/dist/pixoo/micro-glyphs.js.map +1 -1
- package/dist/pixoo/official-dot-glyphs.generated.d.ts +1 -1
- package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -1
- package/dist/pixoo/official-dot-glyphs.generated.js +47 -0
- package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -1
- package/dist/pixoo/pixoo-discover.d.ts +8 -1
- package/dist/pixoo/pixoo-discover.d.ts.map +1 -1
- package/dist/pixoo/pixoo-discover.js +15 -8
- package/dist/pixoo/pixoo-discover.js.map +1 -1
- package/dist/pixoo/pixoo-renderer.d.ts +4 -2
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +105 -36
- package/dist/pixoo/pixoo-renderer.js.map +1 -1
- package/dist/pixoo/pixoo-settings.d.ts +19 -2
- package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
- package/dist/pixoo/pixoo-settings.js +22 -4
- package/dist/pixoo/pixoo-settings.js.map +1 -1
- package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
- package/dist/pixoo/pixoo-sprites.js +2 -0
- package/dist/pixoo/pixoo-sprites.js.map +1 -1
- package/dist/python-ble-runtime.d.ts +2 -0
- package/dist/python-ble-runtime.d.ts.map +1 -1
- package/dist/python-ble-runtime.js +101 -7
- package/dist/python-ble-runtime.js.map +1 -1
- package/dist/session-aggregator.d.ts +9 -1
- package/dist/session-aggregator.d.ts.map +1 -1
- package/dist/session-aggregator.js +1 -1
- package/dist/session-aggregator.js.map +1 -1
- package/dist/session-registry.d.ts +71 -3
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +126 -19
- package/dist/session-registry.js.map +1 -1
- package/dist/subagent-timeline.d.ts +38 -7
- package/dist/subagent-timeline.d.ts.map +1 -1
- package/dist/subagent-timeline.js +154 -23
- package/dist/subagent-timeline.js.map +1 -1
- package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -1
- package/dist/timebox/timebox-daemon-sync.js +11 -1
- package/dist/timebox/timebox-daemon-sync.js.map +1 -1
- package/dist/timeline-store.d.ts.map +1 -1
- package/dist/timeline-store.js +26 -1
- package/dist/timeline-store.js.map +1 -1
- package/dist/tui/renderer.d.ts.map +1 -1
- package/dist/tui/renderer.js +13 -1
- package/dist/tui/renderer.js.map +1 -1
- package/dist/tui/terrarium.d.ts.map +1 -1
- package/dist/tui/terrarium.js +10 -5
- package/dist/tui/terrarium.js.map +1 -1
- package/dist/usage-api.d.ts +28 -1
- package/dist/usage-api.d.ts.map +1 -1
- package/dist/usage-api.js +74 -33
- package/dist/usage-api.js.map +1 -1
- package/dist/usage-event.d.ts.map +1 -1
- package/dist/usage-event.js +2 -17
- package/dist/usage-event.js.map +1 -1
- package/dist/utils/redact-secrets.d.ts +3 -0
- package/dist/utils/redact-secrets.d.ts.map +1 -0
- package/dist/utils/redact-secrets.js +29 -0
- package/dist/utils/redact-secrets.js.map +1 -0
- package/dist/windows-service.d.ts +2 -1
- package/dist/windows-service.d.ts.map +1 -1
- package/dist/windows-service.js +6 -3
- package/dist/windows-service.js.map +1 -1
- package/package.json +3 -3
package/dist/daemon-server.js
CHANGED
|
@@ -28,7 +28,7 @@ import { createPushChannelHandler } from './session-push-channel.js';
|
|
|
28
28
|
import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, advanceAskUserQuestionOverlay, getAskUserQuestionAnswers, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
|
|
29
29
|
import { askEchoMatches, askGateDecision, askPressVerdict } from './ask-gate.js';
|
|
30
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,
|
|
31
|
+
import { shouldHoldPreToolUse, beginAskGate, gateReleased, buildGateQuestion, buildAskAnswerReason, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirectiveForStop, clearOnUserPrompt, clearSession as clearSteeringSession, } from './observed-steering.js';
|
|
32
32
|
import { notePermissionPromptShown, noteToolEnd, steeringSnapshot, } from './observed-steering.js';
|
|
33
33
|
import { resolveSessionIdPrefix } from './session-id-resolve.js';
|
|
34
34
|
import { injectObservedSelection, injectObservedText } from './observed-inject.js';
|
|
@@ -72,8 +72,11 @@ const ASK_GATE_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGE
|
|
|
72
72
|
const APME_ABANDONED_RUN_STALE_SEC = Math.max(600, Number(process.env.AGENTDECK_APME_ABANDON_SEC) || 7200);
|
|
73
73
|
import { VoiceManager } from './voice.js';
|
|
74
74
|
import { VoiceAssistantManager } from './voice-assistant.js';
|
|
75
|
-
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon,
|
|
76
|
-
import {
|
|
75
|
+
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, waitForPortBindable, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
|
|
76
|
+
import { isForeignDaemon } from './daemon-takeover.js';
|
|
77
|
+
import { loadDaemonSettings } from './daemon-settings.js';
|
|
78
|
+
import { resolveDaemonPort, describeDaemonPortSource, PREFERRED_PORT_RECLAIM_MS, } from './daemon-port.js';
|
|
79
|
+
import { fetchUsageFromApi, resetConsecutiveFailures } from './usage-api.js';
|
|
77
80
|
import { getOrCreateToken, isLocalConnection, validateToken } from './auth.js';
|
|
78
81
|
import { buildPublicHealth, gateHttpRequest, isAuthorizedHttpRequest } from './http-auth-gate.js';
|
|
79
82
|
import { closePairingWindow, getPairingWindowStatus, openPairingWindow, pairingWindowOpen, redeemPairingCode, } from './pairing-window.js';
|
|
@@ -85,16 +88,20 @@ import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
|
|
|
85
88
|
import { enableDebugLog, debug, debugThrottled } from './logger.js';
|
|
86
89
|
import { CodexOtelTracker, CODEX_OTEL_TRACES_PATH, spanNameSummary } from './codex-otel.js';
|
|
87
90
|
import { HookCodexSessions } from './hook-codex-sessions.js';
|
|
91
|
+
import { ObservedTurnWatchdogs } from './observed-turn-watchdogs.js';
|
|
88
92
|
import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
|
|
89
93
|
import { FallbackTaskTimeline } from './fallback-task-timeline.js';
|
|
90
94
|
import { handleApmeRequest } from './apme/http.js';
|
|
91
95
|
import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
|
|
92
96
|
import { transcriptTimelineForSession, lastAssistantTextFromTranscript, lastAssistantTextForSession, } from './session-transcript-timeline.js';
|
|
97
|
+
import { kiroTimelineForSession } from './kiro-transcript-timeline.js';
|
|
98
|
+
import { KiroTimelineFeed } from './kiro-timeline-feed.js';
|
|
93
99
|
import { DeviceVoiceReplyRouter, speakableReply, spokenDigest, pcmFromWav, } from './device-voice-reply.js';
|
|
94
100
|
import { rawSessionId } from '@agentdeck/shared';
|
|
95
101
|
import { codexTurnOutcomeFromRollout, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
|
|
96
102
|
import { callFoundationModelsHelper } from './foundation-models-helper.js';
|
|
97
103
|
import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
|
|
104
|
+
import { bindHostFor, daemonModuleConfigs, describeDaemonPosture, resolveDaemonPosture, } from './network-posture.js';
|
|
98
105
|
import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendAuthProvisionToAll, sendWifiProvision, sendWifiProvisionToAll, handleESP32Wake, getESP32Ports, getSerialConnectionStatus, getSerialLastError, getSerialReachableBoards } from './esp32-serial.js';
|
|
99
106
|
import { loadWifiConfig } from './wifi-config.js';
|
|
100
107
|
import { getConnectedAdbDevices, hasAdb, getAdbDeviceCount } from './adb-reverse.js';
|
|
@@ -194,16 +201,50 @@ function saveStagedFw() {
|
|
|
194
201
|
log(`[agentdeck] staged-fw persist failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
195
202
|
}
|
|
196
203
|
}
|
|
204
|
+
/** Resolve a stage target (canonical board, registry key, or IP) to the board
|
|
205
|
+
* string the advert is keyed on.
|
|
206
|
+
*
|
|
207
|
+
* This map is read back by `stagedFwAdvert(device_info.board)`, so anything
|
|
208
|
+
* else stored as the key is an advert that can never fire. Staging with an IP
|
|
209
|
+
* is not a misuse to reject — it is the documented way to name one unit when
|
|
210
|
+
* two share a `board` string (see esp32-ota's ambiguity error) — so normalize
|
|
211
|
+
* it here instead. Consequence worth stating: the feed pull carries only a
|
|
212
|
+
* board, so a stage aimed at one unit adverts to every unit of that board.
|
|
213
|
+
* They run the same image, so that is correct rather than merely tolerable.
|
|
214
|
+
*
|
|
215
|
+
* Unlike `findWifiOtaTarget` this must NOT require an open socket: a
|
|
216
|
+
* wake-sync-sleep board holds none by design, which is the entire reason
|
|
217
|
+
* staging exists. An unrecognized target is kept verbatim — staging for a
|
|
218
|
+
* board this daemon has not met yet is legitimate. */
|
|
219
|
+
function resolveStagedFwBoard(target) {
|
|
220
|
+
for (const [key, device] of wifiEsp32Devices) {
|
|
221
|
+
if (key === target || device.board === target || device.ip === target)
|
|
222
|
+
return device.board;
|
|
223
|
+
}
|
|
224
|
+
// A pull-sync client ages out of the WS roster between wakes; the feed
|
|
225
|
+
// tracker keeps its IP→board memory precisely for that gap.
|
|
226
|
+
const puller = feedPulls.clients().find((c) => c.client === target || c.board === target);
|
|
227
|
+
return puller?.board ?? target;
|
|
228
|
+
}
|
|
197
229
|
/** Stage a build for a board. Reads the file once to fingerprint it; the file
|
|
198
230
|
* itself is re-read at device download time (a rebuild at the same path is
|
|
199
|
-
* re-fingerprinted by re-staging).
|
|
200
|
-
|
|
231
|
+
* re-fingerprinted by re-staging).
|
|
232
|
+
*
|
|
233
|
+
* `pullSeen` reports whether this daemon has actually observed a feed pull
|
|
234
|
+
* from the board, because "staged" proves neither transfer nor install and a
|
|
235
|
+
* bare success reads as if it did. Only pull-sync firmware fetches a staged
|
|
236
|
+
* image; a board that holds a live WS never asks, so its stage sits forever
|
|
237
|
+
* while the CLI says it is handled. It is daemon-lifetime state, so `false`
|
|
238
|
+
* means "not seen since this daemon started" — not "cannot pull". */
|
|
239
|
+
function stageEsp32Fw(target, firmwarePath) {
|
|
240
|
+
const board = resolveStagedFwBoard(target);
|
|
201
241
|
const firmware = readFileSync(firmwarePath);
|
|
202
242
|
const md5 = createHash('md5').update(firmware).digest('hex');
|
|
203
243
|
stagedFwByBoard.set(board, { firmwarePath, md5, size: firmware.length, stagedAt: Date.now() });
|
|
204
244
|
saveStagedFw();
|
|
205
|
-
|
|
206
|
-
|
|
245
|
+
const pullSeen = feedPulls.clients().some((c) => c.board === board);
|
|
246
|
+
log(`[agentdeck] staged firmware for ${board}: ${firmware.length} bytes md5=${md5} — ${pullSeen ? 'installs on its next feed pull' : 'NO feed pull seen from this board since daemon start'}`);
|
|
247
|
+
return { board, bytes: firmware.length, md5, pullSeen };
|
|
207
248
|
}
|
|
208
249
|
/** The staged advert for a feed response, re-validated against the file on
|
|
209
250
|
* disk so a rebuilt-in-place binary never adverts a stale md5. */
|
|
@@ -483,6 +524,11 @@ export const __wifiOtaTestApi = {
|
|
|
483
524
|
unregisterWifiEsp32Socket,
|
|
484
525
|
handleEsp32OtaReply,
|
|
485
526
|
performWifiEsp32Ota,
|
|
527
|
+
resolveStagedFwBoard,
|
|
528
|
+
stageEsp32Fw,
|
|
529
|
+
stagedFwAdvert,
|
|
530
|
+
feedPulls,
|
|
531
|
+
clearStagedFwForTest: () => { stagedFwByBoard.clear(); },
|
|
486
532
|
};
|
|
487
533
|
async function performWifiEsp32Ota(core, target, firmwarePath) {
|
|
488
534
|
const { key, device } = findWifiOtaTarget(target);
|
|
@@ -580,30 +626,6 @@ async function performWifiEsp32Ota(core, target, firmwarePath) {
|
|
|
580
626
|
throw err;
|
|
581
627
|
}
|
|
582
628
|
}
|
|
583
|
-
function loadDaemonSettings() {
|
|
584
|
-
// Newest settings.json across candidate data dirs — mirrors the
|
|
585
|
-
// daemon.json/sessions.json cross-dir discovery (and honors the
|
|
586
|
-
// AGENTDECK_DATA_DIR test override, which the old hardcoded
|
|
587
|
-
// ~/.agentdeck path ignored). The App Store sandbox container is
|
|
588
|
-
// intentionally NOT a candidate (TCC hang risk — see
|
|
589
|
-
// getCandidateDataDirs), so settings written by the sandboxed Swift
|
|
590
|
-
// app stay invisible here; that coexistence limit is documented in
|
|
591
|
-
// docs/appstore-feature-matrix.md.
|
|
592
|
-
let best = null;
|
|
593
|
-
for (const dir of getCandidateDataDirs()) {
|
|
594
|
-
try {
|
|
595
|
-
const path = join(dir, 'settings.json');
|
|
596
|
-
const mtime = statSync(path).mtimeMs;
|
|
597
|
-
if (best && best.mtime >= mtime)
|
|
598
|
-
continue;
|
|
599
|
-
best = { mtime, parsed: JSON.parse(readFileSync(path, 'utf-8')) };
|
|
600
|
-
}
|
|
601
|
-
catch {
|
|
602
|
-
// Missing or unreadable — skip this candidate.
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
return best?.parsed ?? {};
|
|
606
|
-
}
|
|
607
629
|
function latestTimelinePath() {
|
|
608
630
|
let best = null;
|
|
609
631
|
for (const dir of getCandidateDataDirs()) {
|
|
@@ -696,13 +718,15 @@ async function fetchUsageViaWs(siblings) {
|
|
|
696
718
|
export async function fetchUsageRelayed(selfPort) {
|
|
697
719
|
const sessions = listActiveSessions();
|
|
698
720
|
const siblings = sessions.filter(s => s.port !== selfPort && s.agentType !== 'daemon');
|
|
721
|
+
// A sibling's reading is a real successful one, not a failure fallback, so it
|
|
722
|
+
// is `fresh` — its age contract is the 5-minute gate inside fetchUsageViaHttp.
|
|
699
723
|
if (siblings.length > 0) {
|
|
700
724
|
const httpResult = await fetchUsageViaHttp(siblings);
|
|
701
725
|
if (httpResult)
|
|
702
|
-
return httpResult.usage;
|
|
726
|
+
return { data: httpResult.usage, fresh: true };
|
|
703
727
|
const wsResult = await fetchUsageViaWs(siblings);
|
|
704
728
|
if (wsResult)
|
|
705
|
-
return wsResult;
|
|
729
|
+
return { data: wsResult, fresh: true };
|
|
706
730
|
// Relay is only an optimization to avoid multiple API pollers. A PTY session
|
|
707
731
|
// bridge only refreshes usage when it has *direct* inbound WS clients, but
|
|
708
732
|
// dashboards/Stream Deck connect to the daemon (sole entry point) — so a
|
|
@@ -767,15 +791,19 @@ export function classifyObservedHookEvent(eventName, mapped) {
|
|
|
767
791
|
if (eventName === 'codex_subagent_start' || eventName === 'codex_subagent_stop') {
|
|
768
792
|
return { boundary: eventName, agentType: 'codex-cli' };
|
|
769
793
|
}
|
|
770
|
-
const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
|
|
794
|
+
const prefixed = /^(codex|opencode|antigravity|kiro|kiro_ide)_(agent_spawn|session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
|
|
771
795
|
.exec(eventName);
|
|
772
796
|
if (!prefixed)
|
|
773
797
|
return { boundary: mapped, agentType: 'claude-code' };
|
|
774
798
|
return {
|
|
775
|
-
boundary: prefixed[2] === 'turn_complete' ? 'stop'
|
|
799
|
+
boundary: prefixed[2] === 'turn_complete' ? 'stop'
|
|
800
|
+
: prefixed[2] === 'agent_spawn' ? 'session_start'
|
|
801
|
+
: prefixed[2],
|
|
776
802
|
agentType: prefixed[1] === 'codex' ? 'codex-cli'
|
|
777
803
|
: prefixed[1] === 'opencode' ? 'opencode'
|
|
778
|
-
: 'antigravity'
|
|
804
|
+
: prefixed[1] === 'antigravity' ? 'antigravity'
|
|
805
|
+
: prefixed[1] === 'kiro_ide' ? 'kiro-ide'
|
|
806
|
+
: 'kiro-cli',
|
|
779
807
|
};
|
|
780
808
|
}
|
|
781
809
|
function buildNodeModuleHealth(startedModules) {
|
|
@@ -901,6 +929,10 @@ export async function startDaemon(opts) {
|
|
|
901
929
|
// CLI --wake-word flag OR settings.json wakeWord: true
|
|
902
930
|
const settings = loadDaemonSettings();
|
|
903
931
|
const wakeWordEnabled = opts.wakeWord || settings.wakeWord === true;
|
|
932
|
+
// Network posture (bind address + which device modules may run). Resolved once
|
|
933
|
+
// here so the bind, the module set, and the startup log cannot disagree about
|
|
934
|
+
// what this daemon is allowed to do.
|
|
935
|
+
const posture = resolveDaemonPosture({ local: opts.local, loopback: opts.loopback });
|
|
904
936
|
// ===== Singleton guard + port allocation =====
|
|
905
937
|
// 1. Check daemon.json and sessions.json for existing daemon
|
|
906
938
|
const existingInfo = readDaemonInfo();
|
|
@@ -948,32 +980,46 @@ export async function startDaemon(opts) {
|
|
|
948
980
|
removeDaemonSession(existingSession);
|
|
949
981
|
}
|
|
950
982
|
}
|
|
951
|
-
// 2. Determine port —
|
|
952
|
-
|
|
983
|
+
// 2. Determine the preferred port — the one this daemon intends to serve.
|
|
984
|
+
// `opts.port` is already resolved by the CLI (flag › AGENTDECK_DAEMON_PORT ›
|
|
985
|
+
// settings.json `daemonPort` › 9120); resolving again here covers the
|
|
986
|
+
// library entry point, and both paths read the same sources.
|
|
987
|
+
const preferred = opts.port !== undefined
|
|
988
|
+
? { port: opts.port, source: opts.portSource ?? 'flag' }
|
|
989
|
+
: resolveDaemonPort();
|
|
990
|
+
const requestedPort = preferred.port;
|
|
953
991
|
let port = requestedPort;
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
}
|
|
992
|
+
// Probe the preferred port before binding it. This used to run only for the
|
|
993
|
+
// built-in 9120, which made every other preferred port skip the Swift-daemon
|
|
994
|
+
// eviction and the occupied-port check — a persisted `daemonPort: 9200` would
|
|
995
|
+
// have been a second-class port on the same machine. The port a daemon
|
|
996
|
+
// intends to serve gets the same treatment wherever the intent came from.
|
|
997
|
+
const preferredOccupant = await probeDaemonHealth(requestedPort);
|
|
998
|
+
if (preferredOccupant) {
|
|
999
|
+
if (preferredOccupant.mode === 'daemon' && isForeignDaemon(preferredOccupant)) {
|
|
1000
|
+
// Another OS user's daemon. Neither evict it nor concede to it — this
|
|
1001
|
+
// user still gets a daemon, on a port of their own.
|
|
1002
|
+
log(`[agentdeck] Port ${requestedPort} is held by another user's daemon — leaving it alone and taking a free port.`);
|
|
1003
|
+
port = await findAvailablePort();
|
|
1004
|
+
}
|
|
1005
|
+
else if (preferredOccupant.mode === 'daemon') {
|
|
1006
|
+
if (preferredOccupant.isSwift) {
|
|
1007
|
+
log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
|
|
1008
|
+
await requestDaemonShutdown(requestedPort);
|
|
1009
|
+
await waitForDaemonExit(requestedPort);
|
|
1010
|
+
await waitForPortBindable(requestedPort);
|
|
970
1011
|
}
|
|
971
1012
|
else {
|
|
972
|
-
//
|
|
973
|
-
log(`[agentdeck]
|
|
974
|
-
|
|
1013
|
+
// Daemon alive but not in our registry — race condition or stale state
|
|
1014
|
+
log(`[agentdeck] Daemon already running on port ${requestedPort} (detected via /health).`);
|
|
1015
|
+
process.exit(0);
|
|
975
1016
|
}
|
|
976
1017
|
}
|
|
1018
|
+
else {
|
|
1019
|
+
// Port occupied by non-daemon (e.g. session bridge) — find alternative
|
|
1020
|
+
log(`[agentdeck] Port ${requestedPort} occupied (${preferredOccupant.mode ?? 'unknown'}), finding alternative...`);
|
|
1021
|
+
port = await findAvailablePort();
|
|
1022
|
+
}
|
|
977
1023
|
}
|
|
978
1024
|
// 3. Cross-implementation sweep of the daemon port window. File discovery
|
|
979
1025
|
// has two blind spots that the checks above never cover: the App Store
|
|
@@ -985,6 +1031,14 @@ export async function startDaemon(opts) {
|
|
|
985
1031
|
// a live Node daemon wins and we exit.
|
|
986
1032
|
const strayDaemons = await scanDaemonPortWindow(new Set([requestedPort]));
|
|
987
1033
|
for (const stray of strayDaemons) {
|
|
1034
|
+
if (isForeignDaemon(stray.health)) {
|
|
1035
|
+
// Not a stray — someone else's daemon, doing its job for its own user.
|
|
1036
|
+
// This sweep runs on every start, so without this check one user
|
|
1037
|
+
// starting a CLI daemon shuts down every colleague's macOS app on the
|
|
1038
|
+
// box (docs/ENTERPRISE-ROADMAP.md §1.3).
|
|
1039
|
+
log(`[agentdeck] Port ${stray.port} is held by another user's daemon — not touching it.`);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
988
1042
|
if (stray.health.isSwift) {
|
|
989
1043
|
log(`[agentdeck] Swift daemon detected on fallback port ${stray.port}. Requesting shutdown to take over...`);
|
|
990
1044
|
await requestDaemonShutdown(stray.port);
|
|
@@ -1017,6 +1071,53 @@ export async function startDaemon(opts) {
|
|
|
1017
1071
|
* anything arrived SINCE this turn opened.
|
|
1018
1072
|
*/
|
|
1019
1073
|
const hookSessionLastSeenAt = new Map();
|
|
1074
|
+
/**
|
|
1075
|
+
* Missed-Stop recovery for observed Claude sessions (plain `claude` in the
|
|
1076
|
+
* user's own terminal — no PTY, hooks only).
|
|
1077
|
+
*
|
|
1078
|
+
* The Stop hook is the sole authority that closes a Claude turn, and its
|
|
1079
|
+
* curl is fire-and-forget with a tight budget, so it drops. `agentdeck
|
|
1080
|
+
* claude` has recovered from that since 1.0.20; observed sessions had
|
|
1081
|
+
* nothing, which is the majority of real usage. Recovery re-enters this
|
|
1082
|
+
* daemon's own `/hooks/Stop` over loopback rather than reimplementing that
|
|
1083
|
+
* branch — see the class doc for why the same path matters.
|
|
1084
|
+
*
|
|
1085
|
+
* Declared before the HTTP server (hooks arrive the moment the port binds)
|
|
1086
|
+
* and, like every other route, trusted purely because it is loopback: the
|
|
1087
|
+
* auth gate treats same-machine as fully authorized, so no token is
|
|
1088
|
+
* involved and none is minted.
|
|
1089
|
+
*/
|
|
1090
|
+
const observedStopWatchdogs = new ObservedTurnWatchdogs({
|
|
1091
|
+
onMissedStop: ({ sessionId, transcriptPath, reason, cwd, projectName }) => {
|
|
1092
|
+
const url = `http://127.0.0.1:${port}/hooks/Stop`;
|
|
1093
|
+
const controller = new AbortController();
|
|
1094
|
+
// Bounded like every other peer await, loopback or not: a daemon
|
|
1095
|
+
// mid-shutdown accepts the socket and never answers, and an unbounded
|
|
1096
|
+
// wait there would pin the recovery forever.
|
|
1097
|
+
const timer = setTimeout(() => controller.abort(), 2_000);
|
|
1098
|
+
void fetch(url, {
|
|
1099
|
+
method: 'POST',
|
|
1100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1101
|
+
signal: controller.signal,
|
|
1102
|
+
body: JSON.stringify({
|
|
1103
|
+
session_id: sessionId,
|
|
1104
|
+
transcript_path: transcriptPath,
|
|
1105
|
+
// Tags the APME turn `end_source='synthetic_stop'`, which is the
|
|
1106
|
+
// standing measurement of how often the real hook drops — unless
|
|
1107
|
+
// the user cancelled, in which case no Stop was ever due and the
|
|
1108
|
+
// turn must not be charged to that rate.
|
|
1109
|
+
synthetic_stop: true,
|
|
1110
|
+
...(reason === 'interrupted' ? { interrupted: true } : {}),
|
|
1111
|
+
// Same for a client abort (usage limit, auth, API error): Claude
|
|
1112
|
+
// Code fires no Stop for it, so nothing was lost to recover.
|
|
1113
|
+
...(reason === 'aborted' ? { aborted: true } : {}),
|
|
1114
|
+
...(cwd ? { cwd } : {}),
|
|
1115
|
+
...(projectName ? { project_name: projectName } : {}),
|
|
1116
|
+
}),
|
|
1117
|
+
}).catch(() => { })
|
|
1118
|
+
.finally(() => clearTimeout(timer));
|
|
1119
|
+
},
|
|
1120
|
+
});
|
|
1020
1121
|
// Codex OTel span state. Declared before the HTTP server because the
|
|
1021
1122
|
// `/otel/v1/traces` handler closes over it — Codex's exporter can POST before
|
|
1022
1123
|
// the rest of daemon startup finishes.
|
|
@@ -1030,6 +1131,9 @@ export async function startDaemon(opts) {
|
|
|
1030
1131
|
// the rest of startup finishes. A later `const` would leave that window
|
|
1031
1132
|
// throwing on the temporal dead zone.
|
|
1032
1133
|
const passiveSessionObserver = new PassiveSessionObserver();
|
|
1134
|
+
/** Produces live timeline rows for observed Kiro sessions — see the class
|
|
1135
|
+
* doc for why Kiro needs a producer when hook agents do not. */
|
|
1136
|
+
const kiroTimelineFeed = new KiroTimelineFeed();
|
|
1033
1137
|
/** Observed Claude sessions whose AskUserQuestion PreToolUse is held open for
|
|
1034
1138
|
* a device answer, keyed by the bare Claude session UUID. Its presence is
|
|
1035
1139
|
* what makes the session's options pressable (`liveAnswerable`) and what
|
|
@@ -1255,6 +1359,11 @@ export async function startDaemon(opts) {
|
|
|
1255
1359
|
// Workers require this flag before remote-attaching; the Swift daemon
|
|
1256
1360
|
// does not advertise it and is therefore never selected as a remote hub.
|
|
1257
1361
|
sameSocketControl: true,
|
|
1362
|
+
// Network posture, so `agentdeck daemon restart` can carry the running
|
|
1363
|
+
// daemon's posture across the restart instead of silently downgrading
|
|
1364
|
+
// an enterprise install back to "advertise everything". Local-only
|
|
1365
|
+
// payload; it is diagnostic, not a credential.
|
|
1366
|
+
posture,
|
|
1258
1367
|
modules: moduleHealthProvider(),
|
|
1259
1368
|
apme: apme
|
|
1260
1369
|
? {
|
|
@@ -1887,13 +1996,22 @@ export async function startDaemon(opts) {
|
|
|
1887
1996
|
const earlyHookProject = (typeof json.project_name === 'string' && json.project_name)
|
|
1888
1997
|
? json.project_name
|
|
1889
1998
|
: (earlyHookCwd ? earlyHookCwd.split('/').filter(Boolean).pop() : undefined);
|
|
1890
|
-
const
|
|
1999
|
+
const childResult = subagentTimeline?.handle({
|
|
1891
2000
|
eventName,
|
|
1892
2001
|
payload: json,
|
|
1893
2002
|
sessionId: earlyHookSid,
|
|
1894
2003
|
agentType: hookAgentType,
|
|
1895
2004
|
projectName: earlyHookProject,
|
|
1896
|
-
})
|
|
2005
|
+
});
|
|
2006
|
+
// A child starting or stopping is the ONLY thing that moves a parent's
|
|
2007
|
+
// `subagents` block, and it moves nothing else — the parent's own state
|
|
2008
|
+
// is untouched by design. Without this rebroadcast the census would sit
|
|
2009
|
+
// stale until some unrelated event happened to refresh the list, which
|
|
2010
|
+
// for an idle parent waiting on eight children is never.
|
|
2011
|
+
if (childResult?.censusChangedFor) {
|
|
2012
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2013
|
+
}
|
|
2014
|
+
const childHook = childResult?.childOnly === true;
|
|
1897
2015
|
if (childHook) {
|
|
1898
2016
|
// Child activity is observation-only. Never let child PreToolUse
|
|
1899
2017
|
// reach the parent's approval, STOP, state, or APME paths.
|
|
@@ -2044,6 +2162,15 @@ export async function startDaemon(opts) {
|
|
|
2044
2162
|
// (even one persisted before the restart) must not be force-closed.
|
|
2045
2163
|
hookSessionsSeen.add(hookSid);
|
|
2046
2164
|
hookSessionLastSeenAt.set(hookSid, Date.now());
|
|
2165
|
+
// Missed-Stop recovery for observed Claude sessions. Fed the raw
|
|
2166
|
+
// PascalCase event name — the watchdog's vocabulary is Claude's, and
|
|
2167
|
+
// its transcript probe reads Claude's JSONL, so only Claude sessions
|
|
2168
|
+
// are admitted (Codex/OpenCode carry their own turn-end signals).
|
|
2169
|
+
// A synthetic Stop re-enters here as a normal hook and disarms the
|
|
2170
|
+
// watchdog exactly like the real one would.
|
|
2171
|
+
if (hookAgentType === 'claude-code') {
|
|
2172
|
+
observedStopWatchdogs.noteHookEvent(hookSid, eventName, json);
|
|
2173
|
+
}
|
|
2047
2174
|
// Claude hooks carry `cwd` (the worktree dir), not project_name/path —
|
|
2048
2175
|
// capture it so APME runs are attributable to a specific worktree.
|
|
2049
2176
|
const hookCwd = (typeof json.cwd === 'string' ? json.cwd
|
|
@@ -2205,7 +2332,13 @@ export async function startDaemon(opts) {
|
|
|
2205
2332
|
}
|
|
2206
2333
|
const turnOpen = Boolean(lastStart && lastStart.ts > lastCompletionTs);
|
|
2207
2334
|
const tp = typeof json.transcript_path === 'string' ? json.transcript_path : '';
|
|
2208
|
-
const inlineResponse = [
|
|
2335
|
+
const inlineResponse = [
|
|
2336
|
+
json.last_assistant_message,
|
|
2337
|
+
json.assistant_response,
|
|
2338
|
+
json.response,
|
|
2339
|
+
json.output,
|
|
2340
|
+
json.result,
|
|
2341
|
+
]
|
|
2209
2342
|
.find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
|
|
2210
2343
|
// Codex's stop payload rarely carries the text inline; its rollout
|
|
2211
2344
|
// JSONL (agent_message / task_complete records) is the
|
|
@@ -2242,6 +2375,18 @@ export async function startDaemon(opts) {
|
|
|
2242
2375
|
// response, so a duplicate stop is a no-op instead of a clobber.
|
|
2243
2376
|
apme.collector.setLastClosedTurnResponse(hookSid, responseText);
|
|
2244
2377
|
}
|
|
2378
|
+
// Close the APME turn on the Stop that ended it, tagged with which
|
|
2379
|
+
// Stop that was. Runs AFTER the response capture above (the
|
|
2380
|
+
// trajectory event wants the still-active turn) and is a no-op when
|
|
2381
|
+
// no turn is open, so a duplicate/late stop cannot re-attribute an
|
|
2382
|
+
// already-closed turn. `turnOpen` above is a TIMELINE predicate —
|
|
2383
|
+
// it says nothing about the collector's turn, which is why this is
|
|
2384
|
+
// unconditional rather than nested under it.
|
|
2385
|
+
apme.collector.noteTurnStop(hookSid, {
|
|
2386
|
+
synthetic: json.synthetic_stop === true,
|
|
2387
|
+
interrupted: json.interrupted === true,
|
|
2388
|
+
aborted: json.aborted === true,
|
|
2389
|
+
});
|
|
2245
2390
|
}
|
|
2246
2391
|
if (turnOpen && lastStart) {
|
|
2247
2392
|
const now = Date.now();
|
|
@@ -2442,7 +2587,13 @@ export async function startDaemon(opts) {
|
|
|
2442
2587
|
// by blocking the stop with the directive as the continuation
|
|
2443
2588
|
// reason. Empty queue (the overwhelmingly common case) → empty body,
|
|
2444
2589
|
// Claude ends the turn normally — no stop_hook_active loop possible.
|
|
2445
|
-
|
|
2590
|
+
//
|
|
2591
|
+
// `takeDirectiveForStop` refuses a synthetic Stop (missed-Stop
|
|
2592
|
+
// watchdog): nothing is listening on that response, so draining the
|
|
2593
|
+
// queue there would silently eat the user's queued follow-up.
|
|
2594
|
+
// `clearStop` still runs either way — the transcript proved the turn
|
|
2595
|
+
// ended, so a pending soft-stop is moot however we learned it.
|
|
2596
|
+
const directive = claudeSid ? takeDirectiveForStop(claudeSid, json) : undefined;
|
|
2446
2597
|
if (claudeSid)
|
|
2447
2598
|
clearStop(claudeSid); // turn ended — a pending soft-stop is moot
|
|
2448
2599
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -2680,8 +2831,9 @@ export async function startDaemon(opts) {
|
|
|
2680
2831
|
// pull-sync e-ink clients live on the LAN — and the security boundary is the
|
|
2681
2832
|
// pairing token (http-auth-gate.ts default-denies unauthenticated LAN
|
|
2682
2833
|
// requests; the WS server has always token-gated non-local peers). Users
|
|
2683
|
-
// with no LAN devices can opt into a loopback-only
|
|
2684
|
-
|
|
2834
|
+
// with no LAN devices can opt into a loopback-only posture (issue #145),
|
|
2835
|
+
// which also silences everything the daemon emits (see network-posture.ts).
|
|
2836
|
+
const bindHost = bindHostFor(posture);
|
|
2685
2837
|
await new Promise((resolve, reject) => {
|
|
2686
2838
|
httpServer.on('error', (err) => {
|
|
2687
2839
|
if (err.code === 'EADDRINUSE') {
|
|
@@ -2716,8 +2868,48 @@ export async function startDaemon(opts) {
|
|
|
2716
2868
|
if (occupant?.mode === 'daemon') {
|
|
2717
2869
|
log(`[agentdeck] Port ${requestedPort} reports a daemon but PID ${occupant.pid} is not a live distinct process; treating as stale and falling back.`);
|
|
2718
2870
|
}
|
|
2871
|
+
// Nobody answered on the preferred port, yet it refuses to bind. That is
|
|
2872
|
+
// not a peer to yield to — it is the kernel still holding a port whose
|
|
2873
|
+
// owner has already gone: macOS keeps a NECP reservation for ~14s after
|
|
2874
|
+
// an `NWListener.cancel()` (measured 2026-08-06: bindable at ~17s, with
|
|
2875
|
+
// `lsof` showing zero sockets throughout), and a half-closed socket in
|
|
2876
|
+
// TIME_WAIT/LAST_ACK looks the same from here.
|
|
2877
|
+
//
|
|
2878
|
+
// Without this wait the daemon conceded instantly and moved to 9121 —
|
|
2879
|
+
// permanently, because nothing ever moved it back. Seconds after that
|
|
2880
|
+
// decision the canonical port was free and owned by nobody. Waiting is
|
|
2881
|
+
// the whole recovery: `waitForPortBindable` polls, so a kernel-held port
|
|
2882
|
+
// costs the ~14s it is actually held, and only a genuinely occupied port
|
|
2883
|
+
// pays the full budget before falling back.
|
|
2884
|
+
if (!occupant) {
|
|
2885
|
+
log(`[agentdeck] Port ${requestedPort} is held but nothing answers /health — usually the kernel still `
|
|
2886
|
+
+ `releasing it after the previous owner exited. Waiting up to ${Math.round(PREFERRED_PORT_RECLAIM_MS / 1000)}s…`);
|
|
2887
|
+
if (await waitForPortBindable(requestedPort, PREFERRED_PORT_RECLAIM_MS)) {
|
|
2888
|
+
const bound = await new Promise((resolve) => {
|
|
2889
|
+
const onError = () => resolve(false);
|
|
2890
|
+
httpServer.once('error', onError);
|
|
2891
|
+
httpServer.listen(requestedPort, bindHost, () => {
|
|
2892
|
+
httpServer.removeListener('error', onError);
|
|
2893
|
+
resolve(true);
|
|
2894
|
+
});
|
|
2895
|
+
});
|
|
2896
|
+
if (bound) {
|
|
2897
|
+
log(`[agentdeck] Port ${requestedPort} came free — bound it instead of falling back.`);
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
// Lost it again between the probe bind and ours. Rare, and the
|
|
2901
|
+
// fallback below is exactly the right answer for it.
|
|
2902
|
+
log(`[agentdeck] Port ${requestedPort} was taken again before this daemon could bind it.`);
|
|
2903
|
+
}
|
|
2904
|
+
else {
|
|
2905
|
+
log(`[agentdeck] Port ${requestedPort} is still held after ${Math.round(PREFERRED_PORT_RECLAIM_MS / 1000)}s — `
|
|
2906
|
+
+ `something outside AgentDeck is listening on it, or a sleeping device is holding a half-closed socket.`);
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2719
2909
|
port = await findAvailablePort();
|
|
2720
2910
|
log(`[agentdeck] Port ${requestedPort} grabbed by ${occupant?.mode ?? 'a non-daemon'}, retrying on ${port}...`);
|
|
2911
|
+
log(`[agentdeck] This daemon prefers port ${requestedPort} (${describeDaemonPortSource(preferred.source)}). `
|
|
2912
|
+
+ `Clients resolve the actual port from daemon.json; 'agentdeck daemon restart' aims at the preferred port again.`);
|
|
2721
2913
|
await new Promise((resolve, reject) => {
|
|
2722
2914
|
httpServer.on('error', (e) => reject(e));
|
|
2723
2915
|
httpServer.listen(port, bindHost, () => resolve());
|
|
@@ -2727,11 +2919,11 @@ export async function startDaemon(opts) {
|
|
|
2727
2919
|
throw err;
|
|
2728
2920
|
}
|
|
2729
2921
|
});
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
log(`[agentdeck]
|
|
2922
|
+
log(describeDaemonPosture(posture, port));
|
|
2923
|
+
if (preferred.source === 'settings' || preferred.source === 'env') {
|
|
2924
|
+
// A persisted or environmental port is invisible in the command line that
|
|
2925
|
+
// started this daemon, so the log is the only place it can be discovered.
|
|
2926
|
+
log(`[agentdeck] Preferred port ${requestedPort} comes from ${describeDaemonPortSource(preferred.source)}.`);
|
|
2735
2927
|
}
|
|
2736
2928
|
// Write daemon.json for client discovery (must be after successful bind).
|
|
2737
2929
|
// Keep the original startedAt stable when the self-heal timer rewrites it.
|
|
@@ -2744,8 +2936,14 @@ export async function startDaemon(opts) {
|
|
|
2744
2936
|
httpServer,
|
|
2745
2937
|
isDaemon: true,
|
|
2746
2938
|
});
|
|
2747
|
-
subagentTimeline = new SubagentTimelineTracker((entry) => {
|
|
2748
|
-
|
|
2939
|
+
subagentTimeline = new SubagentTimelineTracker((entry, upsert) => {
|
|
2940
|
+
// A dispatch row is upserted as its burst grows (one row per fan-out, the
|
|
2941
|
+
// `task_start` folding pattern) — emitting a fresh row per child would
|
|
2942
|
+
// spend a 200-entry buffer shared by every session on one workflow.
|
|
2943
|
+
if (upsert)
|
|
2944
|
+
core.bridgeTimeline.upsertEntry(entry, { bypassSuppression: true });
|
|
2945
|
+
else
|
|
2946
|
+
core.bridgeTimeline.addEntry(entry, { bypassSuppression: true });
|
|
2749
2947
|
});
|
|
2750
2948
|
// Hooks resolve a fallback daemon port through daemon.json. Another process
|
|
2751
2949
|
// can delete that file while this daemon remains healthy, which makes every
|
|
@@ -2828,6 +3026,11 @@ export async function startDaemon(opts) {
|
|
|
2828
3026
|
const TURN_WATCHDOG_SILENCE_MS = 3 * 60_000;
|
|
2829
3027
|
const turnWatchdog = setInterval(() => {
|
|
2830
3028
|
const now = Date.now();
|
|
3029
|
+
// Drop per-session missed-Stop watchdogs whose session has gone cold.
|
|
3030
|
+
// Rides this existing tick rather than owning a timer: an observed
|
|
3031
|
+
// session that dies mid-turn never sends SessionEnd, so its watchdog
|
|
3032
|
+
// would otherwise poll for the life of the daemon.
|
|
3033
|
+
observedStopWatchdogs.sweep();
|
|
2831
3034
|
const active = new Set();
|
|
2832
3035
|
for (const [sid, at] of hookSessionLastSeenAt) {
|
|
2833
3036
|
if (now - at <= TURN_WATCHDOG_SILENCE_MS)
|
|
@@ -2912,15 +3115,7 @@ export async function startDaemon(opts) {
|
|
|
2912
3115
|
// Reset backoff from pre-sleep failures and fetch fresh usage after network stabilizes
|
|
2913
3116
|
resetConsecutiveFailures();
|
|
2914
3117
|
setTimeout(() => {
|
|
2915
|
-
fetchUsageRelayed(port).then((
|
|
2916
|
-
if (usage)
|
|
2917
|
-
core.updateApiUsage(usage);
|
|
2918
|
-
else {
|
|
2919
|
-
core.oauthConnected = hasOAuthToken();
|
|
2920
|
-
if (core.cachedApiUsage)
|
|
2921
|
-
core.apiUsageStale = true;
|
|
2922
|
-
}
|
|
2923
|
-
});
|
|
3118
|
+
fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
|
|
2924
3119
|
}, 4000);
|
|
2925
3120
|
});
|
|
2926
3121
|
// Subscribe to sibling session bridges' timelines + modelCatalog relay
|
|
@@ -3015,6 +3210,19 @@ export async function startDaemon(opts) {
|
|
|
3015
3210
|
// So split the event by half instead of by event: the ACCOUNT half (quota,
|
|
3016
3211
|
// scoped caps, extra usage, subscriptions, Codex/Ollama, usageStale) comes
|
|
3017
3212
|
// from the daemon's authoritative aggregate, the SESSION half from the relay.
|
|
3213
|
+
//
|
|
3214
|
+
// That split applies on the `else` branch ONLY. When the relay carries
|
|
3215
|
+
// Claude percentages its event goes out verbatim, so the account half —
|
|
3216
|
+
// `codexRateLimits` included — is the BRIDGE's. Usually identical: both
|
|
3217
|
+
// sides run the same plan-aware rollout read. They can differ only when no
|
|
3218
|
+
// plan-matching rollout exists at all, since the daemon additionally holds
|
|
3219
|
+
// a `codex app-server` reading that a session bridge structurally cannot
|
|
3220
|
+
// (no host processes there) — then successive events alternate between a
|
|
3221
|
+
// real Codex window and a voided one. Left as is deliberately: the only
|
|
3222
|
+
// targeted fix is to overwrite the block from `core.buildUsage()` here, and
|
|
3223
|
+
// that is the call which arms the throttled spawn, on the one path whose
|
|
3224
|
+
// whole history is about not clobbering the dashboard. Tracked as issue
|
|
3225
|
+
// #253, not just as this comment.
|
|
3018
3226
|
if (hasClaudeData) {
|
|
3019
3227
|
core.wsServer.broadcast(evt);
|
|
3020
3228
|
}
|
|
@@ -3039,7 +3247,12 @@ export async function startDaemon(opts) {
|
|
|
3039
3247
|
// driven exclusively by the Ulanzi Studio plugin (`ulanzi-plugin`) over WebSocket;
|
|
3040
3248
|
// the daemon never opens it over HID. Its health is reported via the Ulanzi client
|
|
3041
3249
|
// count in moduleHealthProvider below.
|
|
3042
|
-
const startedModules = await initModules(deviceModules,
|
|
3250
|
+
const startedModules = await initModules(deviceModules, daemonModuleConfigs(posture, serialMode), {
|
|
3251
|
+
port, authToken: core.authToken, projectName: 'AgentDeck', wsServer: core.wsServer,
|
|
3252
|
+
// AdbModule restricts `adb reverse` to USB transports under loopback —
|
|
3253
|
+
// a TCP/mDNS adb device would carry the tunnel over the LAN.
|
|
3254
|
+
loopbackOnly: posture.loopbackOnly,
|
|
3255
|
+
});
|
|
3043
3256
|
moduleHealthProvider = () => {
|
|
3044
3257
|
const modules = buildNodeModuleHealth(startedModules);
|
|
3045
3258
|
const einkDevices = collectEinkDevices();
|
|
@@ -3293,7 +3506,24 @@ export async function startDaemon(opts) {
|
|
|
3293
3506
|
// The observer scans in the background now (collect() returns the cache
|
|
3294
3507
|
// immediately). When a scan lands fresh observations, push them out via
|
|
3295
3508
|
// the debounced broadcast so clients don't wait for the next 10 s poll.
|
|
3296
|
-
passiveSessionObserver.onRefreshed = () =>
|
|
3509
|
+
passiveSessionObserver.onRefreshed = () => {
|
|
3510
|
+
// Kiro pushes nothing: no `kiro_*` hook has ever reached this daemon, so
|
|
3511
|
+
// without a producer here its session shows in the HUD while the timeline
|
|
3512
|
+
// beside it stays empty. #218 taught the per-session QUERY to read Kiro's
|
|
3513
|
+
// own transcript; the main timeline is a STREAM, and this is what feeds it.
|
|
3514
|
+
try {
|
|
3515
|
+
const kiroIds = passiveSessionObserver.collect([])
|
|
3516
|
+
.filter((s) => typeof s.agentType === 'string' && s.agentType.startsWith('kiro'))
|
|
3517
|
+
.map((s) => s.id);
|
|
3518
|
+
for (const entry of kiroTimelineFeed.pump(kiroIds)) {
|
|
3519
|
+
core.bridgeTimeline.addEntry(entry);
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
catch (err) {
|
|
3523
|
+
debug('daemon', `kiro timeline feed failed: ${String(err)}`);
|
|
3524
|
+
}
|
|
3525
|
+
core.maybeBroadcastSessionsList();
|
|
3526
|
+
};
|
|
3297
3527
|
// OTel turn boundaries arrive in milliseconds where the observer only re-reads
|
|
3298
3528
|
// the rollout tail every scan interval, so a span that changed thread state is
|
|
3299
3529
|
// worth a broadcast of its own.
|
|
@@ -3344,15 +3574,24 @@ export async function startDaemon(opts) {
|
|
|
3344
3574
|
// Derive per-session elapsed seconds from startedAt so NTP-less devices
|
|
3345
3575
|
// (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
|
|
3346
3576
|
const now = Date.now();
|
|
3577
|
+
// Live child-agent census. Hooks key children by the BARE session uuid
|
|
3578
|
+
// while these rows are `observed:<agent>:<uuid>`, so normalize before the
|
|
3579
|
+
// lookup — the two id forms are the standing trap here.
|
|
3580
|
+
const subagentCensus = subagentTimeline?.summaries() ?? new Map();
|
|
3347
3581
|
const enrichedSessions = [...sessions, ...observed, ...remote].map((s) => {
|
|
3348
3582
|
// On-demand review badge (REVIEW tile verdict / REVIEWING state) —
|
|
3349
3583
|
// applies to every session type, managed included.
|
|
3350
3584
|
const review = reviewSnapshot(s.id);
|
|
3351
3585
|
const withReview = Object.keys(review).length > 0 ? { ...s, ...review } : s;
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3586
|
+
// Emitted whenever this session has EVER had a child, zeros included: a
|
|
3587
|
+
// field that disappears when the last child exits latches "8 running" on
|
|
3588
|
+
// every client that merges retain-on-absent.
|
|
3589
|
+
const census = subagentCensus.get(rawSessionId(withReview.id));
|
|
3590
|
+
const withCensus = census ? { ...withReview, subagents: census } : withReview;
|
|
3591
|
+
if (withCensus.elapsedSec != null || !withCensus.startedAt)
|
|
3592
|
+
return withCensus;
|
|
3593
|
+
const sec = Math.round((now - Date.parse(withCensus.startedAt)) / 1000);
|
|
3594
|
+
return Number.isFinite(sec) && sec >= 0 ? { ...withCensus, elapsedSec: sec } : withCensus;
|
|
3356
3595
|
});
|
|
3357
3596
|
// SSOT: inject iff Gateway is authenticated (gatewayConnected). Reachability
|
|
3358
3597
|
// / adapter-liveness alone must not materialize a session — that kept a
|
|
@@ -3371,6 +3610,11 @@ export async function startDaemon(opts) {
|
|
|
3371
3610
|
projectName: 'OpenClaw',
|
|
3372
3611
|
modelName: gatewayModelName,
|
|
3373
3612
|
controlMode: 'managed',
|
|
3613
|
+
// Read live from the adapter rather than cached alongside
|
|
3614
|
+
// `gatewaySessionState`: the two would drift, and a row that says
|
|
3615
|
+
// "awaiting_permission" while carrying no options is exactly the
|
|
3616
|
+
// dead end this fixes.
|
|
3617
|
+
approval: gatewayAdapter?.getPendingApproval() ?? null,
|
|
3374
3618
|
});
|
|
3375
3619
|
});
|
|
3376
3620
|
// OpenClaw-specific APME session id. Distinct from `core.sessionId`
|
|
@@ -3432,6 +3676,14 @@ export async function startDaemon(opts) {
|
|
|
3432
3676
|
gatewaySessionState = 'idle';
|
|
3433
3677
|
else if (evt.event === 'permission_prompt')
|
|
3434
3678
|
gatewaySessionState = 'awaiting_permission';
|
|
3679
|
+
// The virtual Gateway row carries the approval's question/options, so
|
|
3680
|
+
// a prompt appearing or clearing changes `sessions_list` — not just
|
|
3681
|
+
// the state event. Devices read their option cells from that list; a
|
|
3682
|
+
// state-only broadcast left them showing the previous prompt (or
|
|
3683
|
+
// none) until some unrelated event forced a refresh.
|
|
3684
|
+
if (evt.event === 'spinner_start' || evt.event === 'idle' || evt.event === 'permission_prompt') {
|
|
3685
|
+
core.broadcastSessionsList().catch(() => { });
|
|
3686
|
+
}
|
|
3435
3687
|
// The OpenClaw adapter emits the real model via a model_info parser
|
|
3436
3688
|
// event, but it only ever updated the display StateMachine — the APME
|
|
3437
3689
|
// run was never told, so every openclaw run persisted model_id=NULL.
|
|
@@ -3487,6 +3739,14 @@ export async function startDaemon(opts) {
|
|
|
3487
3739
|
core.bridgeTimeline.addEntry(enriched);
|
|
3488
3740
|
if (enriched.type === 'tool_request')
|
|
3489
3741
|
bridgeLogStream.trackToolRequest(enriched.raw);
|
|
3742
|
+
// Flip the matching `tool_request` row off `pending`. The resolution
|
|
3743
|
+
// has always been emitted as its own row, so the request row kept
|
|
3744
|
+
// `status: 'pending'` forever — an approval answered minutes ago
|
|
3745
|
+
// still read as outstanding in the timeline. `updateEntryStatus`
|
|
3746
|
+
// existed for exactly this and had no production caller.
|
|
3747
|
+
if (enriched.type === 'tool_resolved' && enriched.approvalId) {
|
|
3748
|
+
core.bridgeTimeline.updateEntryStatus(enriched.approvalId, enriched.status === 'approved' ? 'approved' : 'denied');
|
|
3749
|
+
}
|
|
3490
3750
|
}
|
|
3491
3751
|
break;
|
|
3492
3752
|
case 'connection': {
|
|
@@ -3800,6 +4060,17 @@ export async function startDaemon(opts) {
|
|
|
3800
4060
|
}
|
|
3801
4061
|
catch { /* read-only best effort */ }
|
|
3802
4062
|
}
|
|
4063
|
+
// Kiro keeps its transcript in its OWN store, so the Claude reader
|
|
4064
|
+
// above can never find one — and no `kiro_*` hook has ever reached
|
|
4065
|
+
// this daemon (v2 has no global hook surface at all), so the timeline
|
|
4066
|
+
// store is empty for it too. Without this branch an observed Kiro
|
|
4067
|
+
// session's Detail view is empty whatever it does.
|
|
4068
|
+
if (entries.length === 0 && sessionId.startsWith('observed:kiro')) {
|
|
4069
|
+
try {
|
|
4070
|
+
entries = kiroTimelineForSession(sessionId, { since: sinceMs });
|
|
4071
|
+
}
|
|
4072
|
+
catch { /* read-only best effort */ }
|
|
4073
|
+
}
|
|
3803
4074
|
const historyEvent = buildCappedTimelineHistory(entries, undefined, { sessionId });
|
|
3804
4075
|
if (historyEvent) {
|
|
3805
4076
|
try {
|
|
@@ -4233,12 +4504,7 @@ export async function startDaemon(opts) {
|
|
|
4233
4504
|
return;
|
|
4234
4505
|
}
|
|
4235
4506
|
if (cmd.type === 'query_usage') {
|
|
4236
|
-
fetchUsageRelayed(port).then((
|
|
4237
|
-
if (usage)
|
|
4238
|
-
core.updateApiUsage(usage);
|
|
4239
|
-
else if (core.cachedApiUsage)
|
|
4240
|
-
core.apiUsageStale = true;
|
|
4241
|
-
});
|
|
4507
|
+
fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
|
|
4242
4508
|
}
|
|
4243
4509
|
};
|
|
4244
4510
|
// ── Device-sourced voice ──────────────────────────────────────────────
|
|
@@ -5061,15 +5327,7 @@ export async function startDaemon(opts) {
|
|
|
5061
5327
|
// Fetch usage on connect if stale
|
|
5062
5328
|
const cacheAge = Date.now() - core.lastApiFetchTime;
|
|
5063
5329
|
if (!core.cachedApiUsage || (core.lastApiFetchTime > 0 && cacheAge > 5 * 60 * 1000)) {
|
|
5064
|
-
fetchUsageRelayed(port).then((
|
|
5065
|
-
if (usage)
|
|
5066
|
-
core.updateApiUsage(usage);
|
|
5067
|
-
else {
|
|
5068
|
-
core.oauthConnected = hasOAuthToken();
|
|
5069
|
-
if (core.cachedApiUsage)
|
|
5070
|
-
core.apiUsageStale = true;
|
|
5071
|
-
}
|
|
5072
|
-
});
|
|
5330
|
+
fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
|
|
5073
5331
|
}
|
|
5074
5332
|
});
|
|
5075
5333
|
core.wsServer.onClientDisconnect((ws) => {
|
|
@@ -5286,15 +5544,7 @@ export async function startDaemon(opts) {
|
|
|
5286
5544
|
}
|
|
5287
5545
|
// Initial usage fetch (delayed 10s)
|
|
5288
5546
|
core.addTimeout(setTimeout(() => {
|
|
5289
|
-
fetchUsageRelayed(port).then((
|
|
5290
|
-
if (usage)
|
|
5291
|
-
core.updateApiUsage(usage);
|
|
5292
|
-
else {
|
|
5293
|
-
core.oauthConnected = hasOAuthToken();
|
|
5294
|
-
if (core.cachedApiUsage)
|
|
5295
|
-
core.apiUsageStale = true;
|
|
5296
|
-
}
|
|
5297
|
-
});
|
|
5547
|
+
fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
|
|
5298
5548
|
}, 10_000));
|
|
5299
5549
|
// Backstop sweep for held PreToolUse approval responses whose per-entry timer
|
|
5300
5550
|
// somehow didn't fire — resolves anything older than 60s to "ask" so a held
|
|
@@ -5309,6 +5559,9 @@ export async function startDaemon(opts) {
|
|
|
5309
5559
|
removeDaemonInfo();
|
|
5310
5560
|
focusRelay.stop();
|
|
5311
5561
|
timelineRelay.stop();
|
|
5562
|
+
// Clears every per-session poll timer. A watchdog that survived teardown
|
|
5563
|
+
// would keep firing self-POSTs at a port this daemon no longer owns.
|
|
5564
|
+
observedStopWatchdogs.stop();
|
|
5312
5565
|
voiceAssistant?.stop();
|
|
5313
5566
|
bridgeLogStream.stop();
|
|
5314
5567
|
// Flush synchronously — the process exits a few lines below and a pending
|