@agentdeck/bridge 1.2.0 → 1.2.1
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/apme/classifier.d.ts.map +1 -1
- package/dist/apme/classifier.js +18 -10
- package/dist/apme/classifier.js.map +1 -1
- package/dist/apme/claude-transcript-reader.d.ts +36 -0
- package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
- package/dist/apme/claude-transcript-reader.js +78 -2
- package/dist/apme/claude-transcript-reader.js.map +1 -1
- package/dist/apme/collector.d.ts +109 -3
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +408 -44
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/dashboard-html.d.ts.map +1 -1
- package/dist/apme/dashboard-html.js +4 -2
- package/dist/apme/dashboard-html.js.map +1 -1
- package/dist/apme/graph.d.ts +1 -1
- package/dist/apme/graph.d.ts.map +1 -1
- package/dist/apme/graph.js +51 -1
- package/dist/apme/graph.js.map +1 -1
- package/dist/apme/http.d.ts.map +1 -1
- package/dist/apme/http.js +4 -0
- package/dist/apme/http.js.map +1 -1
- package/dist/apme/index.d.ts +4 -0
- package/dist/apme/index.d.ts.map +1 -1
- package/dist/apme/index.js +13 -1
- package/dist/apme/index.js.map +1 -1
- package/dist/apme/runner.d.ts +90 -0
- package/dist/apme/runner.d.ts.map +1 -1
- package/dist/apme/runner.js +524 -48
- package/dist/apme/runner.js.map +1 -1
- package/dist/apme/sample-to-timeline.d.ts.map +1 -1
- package/dist/apme/sample-to-timeline.js +5 -0
- package/dist/apme/sample-to-timeline.js.map +1 -1
- package/dist/apme/settings.d.ts +8 -0
- package/dist/apme/settings.d.ts.map +1 -1
- package/dist/apme/settings.js +4 -0
- package/dist/apme/settings.js.map +1 -1
- package/dist/apme/store.d.ts +77 -8
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +189 -17
- package/dist/apme/store.js.map +1 -1
- package/dist/apme/task-gradeability.d.ts +82 -0
- package/dist/apme/task-gradeability.d.ts.map +1 -0
- package/dist/apme/task-gradeability.js +126 -0
- package/dist/apme/task-gradeability.js.map +1 -0
- package/dist/awaiting-overlay.d.ts +13 -26
- package/dist/awaiting-overlay.d.ts.map +1 -1
- package/dist/awaiting-overlay.js +7 -40
- package/dist/awaiting-overlay.js.map +1 -1
- package/dist/claude-permission-rules.d.ts +20 -32
- package/dist/claude-permission-rules.d.ts.map +1 -1
- package/dist/claude-permission-rules.js +19 -102
- package/dist/claude-permission-rules.js.map +1 -1
- package/dist/claude-usage-recovery.d.ts +54 -0
- package/dist/claude-usage-recovery.d.ts.map +1 -0
- package/dist/claude-usage-recovery.js +200 -0
- package/dist/claude-usage-recovery.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +28 -8
- package/dist/cli.js.map +1 -1
- package/dist/codex-rollout-response.d.ts +43 -0
- package/dist/codex-rollout-response.d.ts.map +1 -1
- package/dist/codex-rollout-response.js +67 -0
- package/dist/codex-rollout-response.js.map +1 -1
- package/dist/coordination-evidence.d.ts +150 -0
- package/dist/coordination-evidence.d.ts.map +1 -0
- package/dist/coordination-evidence.js +455 -0
- package/dist/coordination-evidence.js.map +1 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +329 -34
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/deferred-reply-read.d.ts +24 -0
- package/dist/deferred-reply-read.d.ts.map +1 -0
- package/dist/deferred-reply-read.js +42 -0
- package/dist/deferred-reply-read.js.map +1 -0
- package/dist/esp32-serial.d.ts +11 -0
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +57 -2
- package/dist/esp32-serial.js.map +1 -1
- package/dist/hook-codex-sessions.d.ts +9 -0
- package/dist/hook-codex-sessions.d.ts.map +1 -1
- package/dist/hook-codex-sessions.js +35 -2
- package/dist/hook-codex-sessions.js.map +1 -1
- package/dist/hook-opencode-sessions.d.ts +62 -0
- package/dist/hook-opencode-sessions.d.ts.map +1 -0
- package/dist/hook-opencode-sessions.js +180 -0
- package/dist/hook-opencode-sessions.js.map +1 -0
- package/dist/hook-response-source.d.ts +40 -0
- package/dist/hook-response-source.d.ts.map +1 -0
- package/dist/hook-response-source.js +31 -0
- package/dist/hook-response-source.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/native-dependency-diagnostics.d.ts +29 -0
- package/dist/native-dependency-diagnostics.d.ts.map +1 -0
- package/dist/native-dependency-diagnostics.js +95 -0
- package/dist/native-dependency-diagnostics.js.map +1 -0
- package/dist/observed-steering.d.ts +4 -4
- package/dist/observed-steering.d.ts.map +1 -1
- package/dist/observed-steering.js +28 -32
- package/dist/observed-steering.js.map +1 -1
- package/dist/openclaw-gateway-stability.d.ts +94 -0
- package/dist/openclaw-gateway-stability.d.ts.map +1 -0
- package/dist/openclaw-gateway-stability.js +176 -0
- package/dist/openclaw-gateway-stability.js.map +1 -0
- package/dist/passive-observer.d.ts +5 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +6 -0
- package/dist/passive-observer.js.map +1 -1
- package/dist/review-runner.js +1 -1
- package/dist/subagent-timeline.d.ts +27 -0
- package/dist/subagent-timeline.d.ts.map +1 -1
- package/dist/subagent-timeline.js +52 -5
- package/dist/subagent-timeline.js.map +1 -1
- package/dist/usage-api.d.ts +3 -0
- package/dist/usage-api.d.ts.map +1 -1
- package/dist/usage-api.js +101 -36
- package/dist/usage-api.js.map +1 -1
- package/dist/usage-event.d.ts.map +1 -1
- package/dist/usage-event.js +2 -1
- package/dist/usage-event.js.map +1 -1
- package/package.json +5 -5
package/dist/daemon-server.js
CHANGED
|
@@ -24,6 +24,8 @@ import { PassiveSessionObserver } from './passive-observer.js';
|
|
|
24
24
|
import { SessionTimelineRelay } from './session-timeline-relay.js';
|
|
25
25
|
import { SessionFocusRelay } from './session-focus-relay.js';
|
|
26
26
|
import { SubagentTimelineTracker } from './subagent-timeline.js';
|
|
27
|
+
import { CoordinationTracker } from './coordination-evidence.js';
|
|
28
|
+
import { HookOpenCodeSessions } from './hook-opencode-sessions.js';
|
|
27
29
|
import { getRemoteSession, getRemoteSender, listRemoteEnriched, } from './remote-sessions.js';
|
|
28
30
|
import { createPushChannelHandler } from './session-push-channel.js';
|
|
29
31
|
import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, advanceAskUserQuestionOverlay, getAskUserQuestionAnswers, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
|
|
@@ -71,13 +73,28 @@ const ASK_GATE_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGE
|
|
|
71
73
|
* invisible to this daemon, so only elapsed silence protects them (runs this
|
|
72
74
|
* daemon still holds are skipped via `collector.isLiveRun`). */
|
|
73
75
|
const APME_ABANDONED_RUN_STALE_SEC = Math.max(600, Number(process.env.AGENTDECK_APME_ABANDON_SEC) || 7200);
|
|
76
|
+
/** Backlog tasks handed to the judge per eval tick when it is idle — see the drain. */
|
|
77
|
+
const APME_TASK_JUDGE_DRAIN_PER_TICK = 1;
|
|
78
|
+
/** How many backlog candidates the drain looks at to find one it may feed.
|
|
79
|
+
* Much larger than the per-tick budget on purpose — see the drain for why.
|
|
80
|
+
* Reading rows is free (one indexed SELECT); only the judge call is serial.
|
|
81
|
+
* Sized so a run of consecutive poison tasks at the head cannot fill the whole
|
|
82
|
+
* window and starve the older backlog behind them the way one task did. */
|
|
83
|
+
const APME_TASK_JUDGE_DRAIN_WINDOW = 200;
|
|
84
|
+
/** How often a judge backend that last probed unavailable is probed again.
|
|
85
|
+
* The probe used to run once, at startup; a local MLX server busy with
|
|
86
|
+
* someone else's inference at that moment answered nothing inside the
|
|
87
|
+
* probe's 8 s, and the backlog drain — gated on that one answer — stayed
|
|
88
|
+
* off for the life of the process (2026-09-03, 289 tasks pending). */
|
|
89
|
+
const APME_JUDGE_REPROBE_MS = 5 * 60_000;
|
|
74
90
|
import { VoiceManager } from './voice.js';
|
|
75
91
|
import { VoiceAssistantManager } from './voice-assistant.js';
|
|
76
92
|
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, waitForPortBindable, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getDataDir, getOwnTimelineFile, } from './session-registry.js';
|
|
77
93
|
import { isForeignDaemon } from './daemon-takeover.js';
|
|
78
94
|
import { loadDaemonSettings } from './daemon-settings.js';
|
|
79
95
|
import { resolveDaemonPort, describeDaemonPortSource, PREFERRED_PORT_RECLAIM_MS, } from './daemon-port.js';
|
|
80
|
-
import { fetchUsageFromApi, resetConsecutiveFailures } from './usage-api.js';
|
|
96
|
+
import { enableClaudeUsageRecovery, fetchUsageFromApi, resetConsecutiveFailures } from './usage-api.js';
|
|
97
|
+
import { stopClaudeUsageRecoveryChildren } from './claude-usage-recovery.js';
|
|
81
98
|
import { getOrCreateToken, isLocalConnection, validateToken } from './auth.js';
|
|
82
99
|
import { buildPublicHealth, gateHttpRequest, isAuthorizedHttpRequest } from './http-auth-gate.js';
|
|
83
100
|
import { closePairingWindow, getPairingWindowStatus, openPairingWindow, pairingWindowOpen, redeemPairingCode, } from './pairing-window.js';
|
|
@@ -90,9 +107,12 @@ import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
|
|
|
90
107
|
import { enableDebugLog, debug, debugThrottled } from './logger.js';
|
|
91
108
|
import { LegacyRearmLedger } from './legacy-rearm-ledger.js';
|
|
92
109
|
import { CodexOtelTracker, CODEX_OTEL_TRACES_PATH, spanNameSummary } from './codex-otel.js';
|
|
93
|
-
import { HookCodexSessions } from './hook-codex-sessions.js';
|
|
110
|
+
import { HookCodexSessions, buildCodexPermissionQuestion } from './hook-codex-sessions.js';
|
|
94
111
|
import { ObservedTurnWatchdogs } from './observed-turn-watchdogs.js';
|
|
95
|
-
import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
|
|
112
|
+
import { getApmeInitFailure, initApme, isTimelineProjectionEnabled, loadApmeConfig, } from './apme/index.js';
|
|
113
|
+
import { taskGradeability, retractUngradeableVerdicts } from './apme/task-gradeability.js';
|
|
114
|
+
import { resolveStopResponse } from './hook-response-source.js';
|
|
115
|
+
import { scheduleDeferredReplyRead } from './deferred-reply-read.js';
|
|
96
116
|
import { FallbackTaskTimeline } from './fallback-task-timeline.js';
|
|
97
117
|
import { handleApmeRequest } from './apme/http.js';
|
|
98
118
|
import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
|
|
@@ -102,7 +122,7 @@ import { KiroTimelineFeed } from './kiro-timeline-feed.js';
|
|
|
102
122
|
import { OpenClawTimelineFeed, OPENCLAW_FEED_INTERVAL_MS } from './openclaw-timeline-feed.js';
|
|
103
123
|
import { DeviceVoiceReplyRouter, speakableReply, spokenDigest, pcmFromWav, } from './device-voice-reply.js';
|
|
104
124
|
import { rawSessionId } from '@agentdeck/shared';
|
|
105
|
-
import { codexTurnOutcomeFromRollout, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
|
|
125
|
+
import { codexTurnOutcomeFromRollout, codexTurnOutcomeFromRolloutPath, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
|
|
106
126
|
import { callFoundationModelsHelper } from './foundation-models-helper.js';
|
|
107
127
|
import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
|
|
108
128
|
import { bindHostFor, daemonModuleConfigs, describeDaemonPosture, resolveDaemonPosture, } from './network-posture.js';
|
|
@@ -114,6 +134,7 @@ import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js
|
|
|
114
134
|
import { loadTimeboxDevices } from './timebox/timebox-settings.js';
|
|
115
135
|
import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
|
|
116
136
|
import { injectOpenClawSession, OPENCLAW_SESSION_ID } from './openclaw-session.js';
|
|
137
|
+
import { GatewayFlapTracker, GATEWAY_FLAP_WINDOW_MS, diagnoseGatewayLog, formatGatewayInstability, readGatewayLogTail, } from './openclaw-gateway-stability.js';
|
|
117
138
|
import { buildCardFeed, buildGlance, projectPortableReaderGlance, applyOutboxDecisions, FeedPullTracker, formatFeedPull, applyPullOtaBootstrap, OutboxIdempotencyLedger, normalizeClientIp, parsePullTelemetry, } from './card-feed.js';
|
|
118
139
|
import { threadModule } from './card-modules.js';
|
|
119
140
|
import { AutonomousPocketEngine, createAutonomousPocketModules, parsePocketAutonomyConfig, } from './pocket-autonomy.js';
|
|
@@ -957,12 +978,15 @@ export function classifyObservedHookEvent(eventName, mapped) {
|
|
|
957
978
|
if (eventName === 'codex_subagent_start' || eventName === 'codex_subagent_stop') {
|
|
958
979
|
return { boundary: eventName, agentType: 'codex-cli' };
|
|
959
980
|
}
|
|
960
|
-
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)$/
|
|
981
|
+
const prefixed = /^(codex|opencode|antigravity|kiro|kiro_ide)_(agent_spawn|session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|interrupt|notification|permission_request|permission_asked|permission_replied)$/
|
|
961
982
|
.exec(eventName);
|
|
962
983
|
if (!prefixed)
|
|
963
984
|
return { boundary: mapped, agentType: 'claude-code' };
|
|
964
985
|
return {
|
|
965
|
-
|
|
986
|
+
// `interrupt` (Codex Ctrl+C) is a turn END with no Stop coming, so it
|
|
987
|
+
// rides the stop boundary; the caller stamps `interrupted` on the payload
|
|
988
|
+
// so the collector records `end_source='interrupted'`.
|
|
989
|
+
boundary: prefixed[2] === 'turn_complete' || prefixed[2] === 'interrupt' ? 'stop'
|
|
966
990
|
: prefixed[2] === 'agent_spawn' ? 'session_start'
|
|
967
991
|
: prefixed[2],
|
|
968
992
|
agentType: prefixed[1] === 'codex' ? 'codex-cli'
|
|
@@ -1303,6 +1327,7 @@ export async function startDaemon(opts) {
|
|
|
1303
1327
|
// Codex sessions known only from `codex_*` hooks — the backstop for when the
|
|
1304
1328
|
// process scan can't see one (lsof timeout, no rollout held open).
|
|
1305
1329
|
const hookCodexSessions = new HookCodexSessions();
|
|
1330
|
+
const hookOpenCodeSessions = new HookOpenCodeSessions();
|
|
1306
1331
|
// Declared before the HTTP server: the PreToolUse route reads it to decide
|
|
1307
1332
|
// whether this daemon can type into a session's terminal, and hooks start
|
|
1308
1333
|
// arriving the moment the port binds — several hundred milliseconds before
|
|
@@ -1353,6 +1378,55 @@ export async function startDaemon(opts) {
|
|
|
1353
1378
|
// `gatewaySessionState` / `gatewayModelName` dedicated fields.
|
|
1354
1379
|
let gatewaySessionState = 'idle';
|
|
1355
1380
|
let gatewayModelName;
|
|
1381
|
+
// Restart-storm detector for the Gateway link. Each disconnect is a flap;
|
|
1382
|
+
// three inside ten minutes is an instability, and the reason is read off
|
|
1383
|
+
// OpenClaw's own log tail because nothing on this side can know it. The
|
|
1384
|
+
// finding rides `/status` + `/health` (`gateway.instability`) and one
|
|
1385
|
+
// `error` timeline row per escalation, so a link that keeps "recovering"
|
|
1386
|
+
// cannot pass for healthy on any surface. Cleared once the window drains.
|
|
1387
|
+
const gatewayFlaps = new GatewayFlapTracker();
|
|
1388
|
+
let gatewayInstability = null;
|
|
1389
|
+
let gatewayInstabilityAssessing = false;
|
|
1390
|
+
async function assessGatewayInstability(nowMs) {
|
|
1391
|
+
if (gatewayInstabilityAssessing)
|
|
1392
|
+
return;
|
|
1393
|
+
const a = gatewayFlaps.assess(nowMs);
|
|
1394
|
+
if (!a.unstable)
|
|
1395
|
+
return;
|
|
1396
|
+
// Escalate only when the count grows; a stable count is old news.
|
|
1397
|
+
if (gatewayInstability && gatewayInstability.flapsInWindow >= a.flapsInWindow)
|
|
1398
|
+
return;
|
|
1399
|
+
gatewayInstabilityAssessing = true;
|
|
1400
|
+
try {
|
|
1401
|
+
const diagnosis = diagnoseGatewayLog(await readGatewayLogTail());
|
|
1402
|
+
gatewayInstability = {
|
|
1403
|
+
flapsInWindow: a.flapsInWindow,
|
|
1404
|
+
windowMs: GATEWAY_FLAP_WINDOW_MS,
|
|
1405
|
+
since: a.since ?? nowMs,
|
|
1406
|
+
lastFlapAt: nowMs,
|
|
1407
|
+
diagnosis,
|
|
1408
|
+
};
|
|
1409
|
+
const line = formatGatewayInstability(gatewayInstability);
|
|
1410
|
+
log(`[agentdeck] ${line}${diagnosis ? ` — ${diagnosis.hint}` : ''}`);
|
|
1411
|
+
core.bridgeTimeline.addEntry({
|
|
1412
|
+
ts: nowMs, type: 'error',
|
|
1413
|
+
raw: line.slice(0, 197),
|
|
1414
|
+
detail: diagnosis ? `${diagnosis.hint}\n\n${diagnosis.line}` : undefined,
|
|
1415
|
+
sessionId: OPENCLAW_SESSION_ID,
|
|
1416
|
+
agentType: 'openclaw',
|
|
1417
|
+
});
|
|
1418
|
+
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
1419
|
+
}
|
|
1420
|
+
finally {
|
|
1421
|
+
gatewayInstabilityAssessing = false;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function settleGatewayInstability(nowMs) {
|
|
1425
|
+
if (gatewayInstability && !gatewayFlaps.assess(nowMs).unstable) {
|
|
1426
|
+
log('[agentdeck] OpenClaw Gateway stable again — instability cleared');
|
|
1427
|
+
gatewayInstability = null;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1356
1430
|
/**
|
|
1357
1431
|
* Snapshot for an `openclaw` state event: the global machine with the
|
|
1358
1432
|
* Gateway-local model pinned back on.
|
|
@@ -1436,6 +1510,9 @@ export async function startDaemon(opts) {
|
|
|
1436
1510
|
// Assigned after BridgeCore exists. The HTTP handler reads it only after
|
|
1437
1511
|
// startup; nullable keeps the tiny listen→core initialization window safe.
|
|
1438
1512
|
let subagentTimeline = null;
|
|
1513
|
+
// Cross-session coordination (spawned workers, peer messages, background
|
|
1514
|
+
// jobs) — the second census axis beside `subagents`. See coordination-evidence.ts.
|
|
1515
|
+
const coordination = new CoordinationTracker();
|
|
1439
1516
|
// The learning pack is immutable for one daemon lifetime. Package upgrades
|
|
1440
1517
|
// arrive with a new AgentDeck build; validating once keeps every sleeping
|
|
1441
1518
|
// reader pull cheap while a corrupt/mislicensed bundle fails closed.
|
|
@@ -1558,6 +1635,9 @@ export async function startDaemon(opts) {
|
|
|
1558
1635
|
res.end(JSON.stringify({
|
|
1559
1636
|
status: 'ok', mode: 'daemon', state: snap.state,
|
|
1560
1637
|
gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
|
|
1638
|
+
// A link that keeps reconnecting reads `connected` at every sample.
|
|
1639
|
+
// This is the field that says the samples were lying (null = stable).
|
|
1640
|
+
gatewayInstability,
|
|
1561
1641
|
uptime: process.uptime(), port, pid: process.pid,
|
|
1562
1642
|
// Which build is SERVING this port. Captured when this process started
|
|
1563
1643
|
// (see daemon-build-identity.ts) and never recomputed, so a rebuild
|
|
@@ -1583,7 +1663,10 @@ export async function startDaemon(opts) {
|
|
|
1583
1663
|
dbPath: apme.store.dbPath,
|
|
1584
1664
|
judgeBackend: apme.runner.lastBackendProbe ?? { status: 'unknown', backend: loadApmeConfig().judge.backend },
|
|
1585
1665
|
}
|
|
1586
|
-
: {
|
|
1666
|
+
: {
|
|
1667
|
+
enabled: false,
|
|
1668
|
+
error: getApmeInitFailure() ?? 'unknown init failure',
|
|
1669
|
+
},
|
|
1587
1670
|
}));
|
|
1588
1671
|
return;
|
|
1589
1672
|
}
|
|
@@ -1743,6 +1826,7 @@ export async function startDaemon(opts) {
|
|
|
1743
1826
|
available: core.cachedGatewayAvailable,
|
|
1744
1827
|
connected: core.cachedGatewayConnected,
|
|
1745
1828
|
hasError: core.cachedGatewayHasError,
|
|
1829
|
+
instability: gatewayInstability,
|
|
1746
1830
|
},
|
|
1747
1831
|
clients: core.wsServer.getClientCount(),
|
|
1748
1832
|
modules: moduleHealthProvider(),
|
|
@@ -2627,6 +2711,15 @@ export async function startDaemon(opts) {
|
|
|
2627
2711
|
json = body ? JSON.parse(body) : {};
|
|
2628
2712
|
}
|
|
2629
2713
|
catch { /* ignore */ }
|
|
2714
|
+
// The hook shell's parent pid (`X-AgentDeck-Pid: $PPID` in the
|
|
2715
|
+
// installed snippet) — the only consent-free session→process link.
|
|
2716
|
+
// Carried inside the payload as `agentdeck_pid` so every consumer
|
|
2717
|
+
// downstream sees one shape; a payload that already names one wins.
|
|
2718
|
+
const pidHeader = req.headers['x-agentdeck-pid'];
|
|
2719
|
+
const headerPid = Number(Array.isArray(pidHeader) ? pidHeader[0] : pidHeader);
|
|
2720
|
+
if (Number.isInteger(headerPid) && headerPid > 1 && json.agentdeck_pid == null) {
|
|
2721
|
+
json.agentdeck_pid = headerPid;
|
|
2722
|
+
}
|
|
2630
2723
|
// Map PascalCase event names to snake_case for state machine + APME
|
|
2631
2724
|
const eventMap = {
|
|
2632
2725
|
SessionStart: 'session_start', SessionEnd: 'session_end',
|
|
@@ -2643,6 +2736,10 @@ export async function startDaemon(opts) {
|
|
|
2643
2736
|
// codex/opencode *state* is owned by the passive observer's turn
|
|
2644
2737
|
// semantics, not these hooks.
|
|
2645
2738
|
const { boundary, agentType: hookAgentType } = classifyObservedHookEvent(eventName, mapped);
|
|
2739
|
+
// A Codex/OpenCode Interrupt is the user's Ctrl+C: the turn ended with
|
|
2740
|
+
// no Stop, and the collector must not read it as a normal stop.
|
|
2741
|
+
if (/_interrupt$/.test(eventName))
|
|
2742
|
+
json.interrupted = true;
|
|
2646
2743
|
const earlyHookSid = typeof json.session_id === 'string' && json.session_id
|
|
2647
2744
|
? json.session_id : 'daemon-hook';
|
|
2648
2745
|
const earlyHookCwd = (typeof json.cwd === 'string' ? json.cwd
|
|
@@ -2665,6 +2762,12 @@ export async function startDaemon(opts) {
|
|
|
2665
2762
|
if (childResult?.censusChangedFor) {
|
|
2666
2763
|
core.broadcastSessionsList().catch(() => { });
|
|
2667
2764
|
}
|
|
2765
|
+
if (childResult?.sampleEvent) {
|
|
2766
|
+
apme?.collector.noteSubagentLifecycle(earlyHookSid, childResult.sampleEvent);
|
|
2767
|
+
}
|
|
2768
|
+
if (childResult?.infoEvent) {
|
|
2769
|
+
apme?.collector.noteInfo(earlyHookSid, childResult.infoEvent);
|
|
2770
|
+
}
|
|
2668
2771
|
const childHook = childResult?.childOnly === true;
|
|
2669
2772
|
if (childHook) {
|
|
2670
2773
|
// Child activity is observation-only. Never let child PreToolUse
|
|
@@ -2686,6 +2789,39 @@ export async function startDaemon(opts) {
|
|
|
2686
2789
|
projectName: earlyHookProject,
|
|
2687
2790
|
toolName: typeof json.tool_name === 'string' ? json.tool_name : undefined,
|
|
2688
2791
|
});
|
|
2792
|
+
// Codex PERM: `PermissionRequest` fires only when Codex is about to
|
|
2793
|
+
// ask the user (approval_policy on-request), so it is a genuine
|
|
2794
|
+
// awaiting signal with no prediction — display-only, respond in the
|
|
2795
|
+
// terminal. Any later lifecycle hook on the session ends the wait:
|
|
2796
|
+
// an approval runs the tool (tool_start/tool_end), a denial or
|
|
2797
|
+
// Ctrl+C ends the turn (stop/interrupt), a new prompt supersedes it.
|
|
2798
|
+
const codexSid = typeof json.session_id === 'string' ? json.session_id : '';
|
|
2799
|
+
if (codexSid) {
|
|
2800
|
+
if (eventName === 'codex_permission_request') {
|
|
2801
|
+
// `sticky`: the transcript-recency drop is a Claude ESC heuristic;
|
|
2802
|
+
// a Codex rollout may be appended while the approval waits, and
|
|
2803
|
+
// the explicit hook clears below already end this wait.
|
|
2804
|
+
setAwaitingOverlay(codexSid, buildCodexPermissionQuestion(json), undefined, { sticky: true });
|
|
2805
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2806
|
+
}
|
|
2807
|
+
else if (getAwaitingOverlay(codexSid)?.kind === 'permission') {
|
|
2808
|
+
if (clearAwaitingOverlay(codexSid))
|
|
2809
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
// OpenCode observed rows: the process scan keys them by PID and knows
|
|
2814
|
+
// nothing about turns or permissions; the observer plugin's hooks key
|
|
2815
|
+
// by OpenCode session id and carry both. See hook-opencode-sessions.ts.
|
|
2816
|
+
if (eventName.startsWith('opencode_')) {
|
|
2817
|
+
hookOpenCodeSessions.note(eventName, {
|
|
2818
|
+
sessionId: typeof json.session_id === 'string' ? json.session_id : undefined,
|
|
2819
|
+
cwd: earlyHookCwd || undefined,
|
|
2820
|
+
projectName: earlyHookProject,
|
|
2821
|
+
toolName: typeof json.tool_name === 'string' ? json.tool_name : undefined,
|
|
2822
|
+
permissionId: typeof json.permission_id === 'string' ? json.permission_id : undefined,
|
|
2823
|
+
title: typeof json.title === 'string' ? json.title : undefined,
|
|
2824
|
+
});
|
|
2689
2825
|
}
|
|
2690
2826
|
// State machine
|
|
2691
2827
|
if (mapped === 'session_start')
|
|
@@ -2751,7 +2887,7 @@ export async function startDaemon(opts) {
|
|
|
2751
2887
|
// never held again this session. User prompt / session end clear
|
|
2752
2888
|
// pending STOP + queued directives (the user took over).
|
|
2753
2889
|
if (mapped === 'tool_end') {
|
|
2754
|
-
noteToolEnd(claudeSid, toolName || undefined);
|
|
2890
|
+
noteToolEnd(claudeSid, toolName || undefined, toolUseId);
|
|
2755
2891
|
}
|
|
2756
2892
|
else if (mapped === 'user_prompt_submit') {
|
|
2757
2893
|
if (clearOnUserPrompt(claudeSid))
|
|
@@ -2850,6 +2986,10 @@ export async function startDaemon(opts) {
|
|
|
2850
2986
|
// Mirrors the Swift daemon's session_end force-close (`hasOpenTurn`
|
|
2851
2987
|
// → interrupted chat_end).
|
|
2852
2988
|
if (boundary === 'session_end') {
|
|
2989
|
+
// Every child the session had ends with it — a lost SubagentStop
|
|
2990
|
+
// must not pin "+N" on a row that no longer exists.
|
|
2991
|
+
subagentTimeline?.forget(hookSid);
|
|
2992
|
+
coordination.forget(hookSid);
|
|
2853
2993
|
const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
|
|
2854
2994
|
let lastStart;
|
|
2855
2995
|
let lastCompletionTs = 0;
|
|
@@ -2909,13 +3049,35 @@ export async function startDaemon(opts) {
|
|
|
2909
3049
|
// codex_* / opencode_* names would silently skip turn management.
|
|
2910
3050
|
apme.collector.ingestHook(hookSid, boundary, json);
|
|
2911
3051
|
}
|
|
3052
|
+
// Coordination evidence carried BY the hook itself: a received
|
|
3053
|
+
// cross-session envelope, a SendMessage call, a `claude -p` launch.
|
|
3054
|
+
// After ingestHook so a prompt's turn is open before the relation
|
|
3055
|
+
// is attached to it. Process-table evidence (ancestry, background
|
|
3056
|
+
// jobs) arrives on the observer tick instead.
|
|
3057
|
+
if (typeof json.agentdeck_pid === 'number') {
|
|
3058
|
+
coordination.registerPid(hookSid, json.agentdeck_pid, passiveSessionObserver.processes());
|
|
3059
|
+
}
|
|
3060
|
+
if (boundary === 'user_prompt_submit' && hookPromptText) {
|
|
3061
|
+
persistRelation(coordination.noteMessageIn(hookSid, hookPromptText));
|
|
3062
|
+
}
|
|
3063
|
+
else if (eventName === 'PostToolUse' || boundary === 'tool_end') {
|
|
3064
|
+
persistRelation(coordination.noteToolCall(hookSid, json.tool_name, json.tool_input));
|
|
3065
|
+
}
|
|
2912
3066
|
// Direct `claude` runs reach the daemon only via these hooks, which
|
|
2913
3067
|
// never carry the model — so every such run persisted model_id=NULL.
|
|
2914
3068
|
// Recover it from the transcript Claude writes. Must run before
|
|
2915
3069
|
// closeRun tears down the session→run mapping.
|
|
2916
3070
|
if (boundary === 'stop' || boundary === 'session_end') {
|
|
3071
|
+
// Codex states its model on the hook itself (`model`); Claude's
|
|
3072
|
+
// transcript holds it. `transcript_path` on a Codex hook is its
|
|
3073
|
+
// ROLLOUT, which the Claude reader cannot parse — so every Codex
|
|
3074
|
+
// run persisted model_id=NULL while the payload said `gpt-5.6`.
|
|
3075
|
+
const inlineModel = typeof json.model === 'string' ? json.model.trim() : '';
|
|
2917
3076
|
const tp = json.transcript_path;
|
|
2918
|
-
if (
|
|
3077
|
+
if (inlineModel) {
|
|
3078
|
+
apme.collector.updateModel(hookSid, inlineModel);
|
|
3079
|
+
}
|
|
3080
|
+
else if (hookAgentType === 'claude-code' && typeof tp === 'string' && tp) {
|
|
2919
3081
|
const model = readModelFromTranscript(tp);
|
|
2920
3082
|
if (model)
|
|
2921
3083
|
apme.collector.updateModel(hookSid, model);
|
|
@@ -2994,18 +3156,31 @@ export async function startDaemon(opts) {
|
|
|
2994
3156
|
json.result,
|
|
2995
3157
|
]
|
|
2996
3158
|
.find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
|
|
2997
|
-
//
|
|
2998
|
-
//
|
|
2999
|
-
//
|
|
3000
|
-
//
|
|
3001
|
-
//
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
const
|
|
3007
|
-
|
|
3008
|
-
:
|
|
3159
|
+
// Where the reply comes from is decided per AGENT, and inline text
|
|
3160
|
+
// wins wherever it exists — it is the hook's own statement of what
|
|
3161
|
+
// the agent said. Otherwise Claude's transcript is read by the
|
|
3162
|
+
// Claude reader and Codex's rollout by the Codex reader. The old
|
|
3163
|
+
// rule was "transcript_path ⇒ Claude reader", and a Codex hook
|
|
3164
|
+
// carries a transcript_path too — its rollout — so every Codex turn
|
|
3165
|
+
// read '' while `last_assistant_message` sat in the same payload
|
|
3166
|
+
// (3 of 128 codex stop-turns held a response in one week, measured
|
|
3167
|
+
// 2026-09-03; the judge scored the rest against silence).
|
|
3168
|
+
const resolved = resolveStopResponse({ agentType: hookAgentType, sessionId: hookSid, inlineResponse, transcriptPath: tp }, {
|
|
3169
|
+
readClaudeTranscript: lastAssistantTextFromTranscript,
|
|
3170
|
+
readCodexRolloutPath: codexTurnOutcomeFromRolloutPath,
|
|
3171
|
+
readCodexRolloutById: codexTurnOutcomeFromRollout,
|
|
3172
|
+
});
|
|
3173
|
+
const rolloutOutcome = resolved.rollout;
|
|
3174
|
+
const responseText = stripUnsafeText(resolved.text);
|
|
3175
|
+
// An empty transcript read AT the Stop is not an answer — Claude is
|
|
3176
|
+
// still writing the record the hook refers to. Re-read shortly and
|
|
3177
|
+
// hand it to the last-closed turn (see deferred-reply-read.ts).
|
|
3178
|
+
if (!responseText && apme && hookAgentType === 'claude-code' && tp) {
|
|
3179
|
+
scheduleDeferredReplyRead(() => stripUnsafeText(lastAssistantTextFromTranscript(tp)), (text) => {
|
|
3180
|
+
apme?.collector.setLastClosedTurnResponse(hookSid, text);
|
|
3181
|
+
debug('APME', `deferred transcript read recovered ${text.length} chars for ${hookSid.slice(0, 8)}`);
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
3009
3184
|
const respRaw = responseText.length > 0
|
|
3010
3185
|
? cleanRawText(responseText.length > 200 ? responseText.slice(0, 197) + '...' : responseText)
|
|
3011
3186
|
: '';
|
|
@@ -3223,6 +3398,7 @@ export async function startDaemon(opts) {
|
|
|
3223
3398
|
// the signature is suppressed for this session.
|
|
3224
3399
|
gateReleased(claudeSid, requestId, {
|
|
3225
3400
|
undecided: decision === 'pass', tool: toolName, toolInput,
|
|
3401
|
+
toolUseId: typeof json.tool_use_id === 'string' ? json.tool_use_id : undefined,
|
|
3226
3402
|
});
|
|
3227
3403
|
clearAwaitingOverlay(claudeSid);
|
|
3228
3404
|
core.broadcastSessionsList().catch(() => { });
|
|
@@ -3583,6 +3759,7 @@ export async function startDaemon(opts) {
|
|
|
3583
3759
|
// Keep the original startedAt stable when the self-heal timer rewrites it.
|
|
3584
3760
|
const daemonInfo = { port, pid: process.pid, startedAt: new Date().toISOString() };
|
|
3585
3761
|
writeDaemonInfo(daemonInfo);
|
|
3762
|
+
enableClaudeUsageRecovery();
|
|
3586
3763
|
// ===== BridgeCore =====
|
|
3587
3764
|
const core = new BridgeCore({
|
|
3588
3765
|
port,
|
|
@@ -3640,7 +3817,8 @@ export async function startDaemon(opts) {
|
|
|
3640
3817
|
// event and WiFi resumes automatically — no state migration needed.
|
|
3641
3818
|
if (SERIAL_FORWARDED_EVENTS.has(event.type) && isWifiEsp32RedundantWithSerial(client))
|
|
3642
3819
|
return null;
|
|
3643
|
-
|
|
3820
|
+
const identity = wifiEsp32IdentityForSocket(client);
|
|
3821
|
+
return prepareForSerial(event, identity?.board ? { deviceInfo: { board: identity.board } } : undefined);
|
|
3644
3822
|
});
|
|
3645
3823
|
// Timeline
|
|
3646
3824
|
//
|
|
@@ -4139,6 +4317,41 @@ export async function startDaemon(opts) {
|
|
|
4139
4317
|
log('[agentdeck] APME timeline projection ENABLED — chat/tool rows derive from SessionSample');
|
|
4140
4318
|
}
|
|
4141
4319
|
log(`[agentdeck] APME enabled — store=${apme.store.dbPath} routes=/apme/*`);
|
|
4320
|
+
// Re-adopt the runs the previous daemon left open BEFORE any hook is
|
|
4321
|
+
// served: a session that carried on across the restart continues its run
|
|
4322
|
+
// instead of stranding it for the reaper (see `rehydrateOpenRuns`).
|
|
4323
|
+
// Rows the OLD reaper stamped `orphaned` on evidence that now reads
|
|
4324
|
+
// `idle_gap` — one idempotent pass, evidence-gated (see the store).
|
|
4325
|
+
try {
|
|
4326
|
+
const reclassified = apme.store.reclassifyReapedTasks();
|
|
4327
|
+
if (reclassified > 0)
|
|
4328
|
+
log(`[agentdeck] APME re-stamped ${reclassified} reaped task(s) as idle_gap — every turn had closed normally`);
|
|
4329
|
+
}
|
|
4330
|
+
catch { /* store logs it */ }
|
|
4331
|
+
// Verdicts the judge reached on tasks it would now decline — scored
|
|
4332
|
+
// against silence, or on a usage-limit notice — are withdrawn so they
|
|
4333
|
+
// stop ranking real work. Bounded to the same 30-day window the backlog
|
|
4334
|
+
// drain reads; idempotent after the first pass.
|
|
4335
|
+
try {
|
|
4336
|
+
const r = retractUngradeableVerdicts(apme.store, Date.now() - 30 * 86_400_000);
|
|
4337
|
+
const n = r.no_reply + r.aborted_only + r.trivial;
|
|
4338
|
+
if (n > 0)
|
|
4339
|
+
log(`[agentdeck] APME withdrew ${n} verdict(s) reached without the agent's work — no reply ${r.no_reply}, client-ended ${r.aborted_only}, trivial ${r.trivial}`);
|
|
4340
|
+
if (r.readmitted > 0)
|
|
4341
|
+
log(`[agentdeck] APME re-admitted ${r.readmitted} declined task(s) to the judge backlog — their tool trajectory is the agent's work`);
|
|
4342
|
+
}
|
|
4343
|
+
catch (err) {
|
|
4344
|
+
log(`[agentdeck] APME verdict retraction failed: ${String(err)}`);
|
|
4345
|
+
}
|
|
4346
|
+
try {
|
|
4347
|
+
const r = apme.collector.rehydrateOpenRuns();
|
|
4348
|
+
if (r.runs > 0) {
|
|
4349
|
+
log(`[agentdeck] APME resumed ${r.runs} open run(s) from the store — ${r.tasks} task(s), ${r.turns} open turn(s), ${r.armed} idle-gap timer(s) re-armed${r.recovered > 0 ? `, ${r.recovered} turn(s) closed from the agent's own record` : ''}`);
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
catch (err) {
|
|
4353
|
+
log(`[agentdeck] APME rehydrate failed: ${String(err)}`);
|
|
4354
|
+
}
|
|
4142
4355
|
// Fire-and-forget judge backend probe. Result is cached on
|
|
4143
4356
|
// apme.runner.lastBackendProbe and surfaced on /health so users discover
|
|
4144
4357
|
// misconfiguration (no MLX server running, missing API key, etc.) without
|
|
@@ -4167,6 +4380,33 @@ export async function startDaemon(opts) {
|
|
|
4167
4380
|
// The observer scans in the background now (collect() returns the cache
|
|
4168
4381
|
// immediately). When a scan lands fresh observations, push them out via
|
|
4169
4382
|
// the debounced broadcast so clients don't wait for the next 10 s poll.
|
|
4383
|
+
// A relation lands on the session's active APME task; a change to any
|
|
4384
|
+
// census rebroadcasts the roster, because a background job starting or a
|
|
4385
|
+
// spawned worker exiting moves nothing else a client could notice.
|
|
4386
|
+
const persistRelation = (rel) => {
|
|
4387
|
+
if (!rel)
|
|
4388
|
+
return false;
|
|
4389
|
+
apme?.collector.noteRelation(rel.sessionId, rel);
|
|
4390
|
+
return true;
|
|
4391
|
+
};
|
|
4392
|
+
// Driven by its own timer, not by `onRefreshed`: that edge fires only when
|
|
4393
|
+
// the observed PROCESS SET changes, and a background job appearing or a
|
|
4394
|
+
// worker finishing changes the process table without changing that set —
|
|
4395
|
+
// the same trap the OpenClaw transcript feed fell into.
|
|
4396
|
+
const coordinationTick = () => {
|
|
4397
|
+
const peers = coordination.mergePeers(passiveSessionObserver.collect([])
|
|
4398
|
+
.filter((s) => typeof s.pid === 'number' && s.pid > 0)
|
|
4399
|
+
.map((s) => ({ sessionId: rawSessionId(s.id), pid: s.pid })));
|
|
4400
|
+
let changed = false;
|
|
4401
|
+
for (const rel of coordination.observe(passiveSessionObserver.processes(), peers)) {
|
|
4402
|
+
if (persistRelation(rel))
|
|
4403
|
+
changed = true;
|
|
4404
|
+
}
|
|
4405
|
+
if (changed)
|
|
4406
|
+
core.broadcastSessionsList().catch(() => { });
|
|
4407
|
+
};
|
|
4408
|
+
const coordinationTimer = setInterval(coordinationTick, 5_000);
|
|
4409
|
+
coordinationTimer.unref?.();
|
|
4170
4410
|
passiveSessionObserver.onRefreshed = () => {
|
|
4171
4411
|
// Kiro pushes nothing: no `kiro_*` hook has ever reached this daemon, so
|
|
4172
4412
|
// without a producer here its session shows in the HUD while the timeline
|
|
@@ -4227,6 +4467,7 @@ export async function startDaemon(opts) {
|
|
|
4227
4467
|
// worth a broadcast of its own.
|
|
4228
4468
|
codexOtel.onChanged = () => core.maybeBroadcastSessionsList();
|
|
4229
4469
|
hookCodexSessions.onChanged = () => core.maybeBroadcastSessionsList();
|
|
4470
|
+
hookOpenCodeSessions.onChanged = () => core.maybeBroadcastSessionsList();
|
|
4230
4471
|
// ===== Gateway adapter lifecycle =====
|
|
4231
4472
|
// (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
|
|
4232
4473
|
// Inject OpenClaw virtual session only after Gateway authentication succeeds.
|
|
@@ -4242,7 +4483,7 @@ export async function startDaemon(opts) {
|
|
|
4242
4483
|
// synthesize a `codex-app` row for any thread the process scan missed —
|
|
4243
4484
|
// Swift's only Codex-app source, here a second opinion on top of the
|
|
4244
4485
|
// observer. See bridge/src/codex-otel.ts.
|
|
4245
|
-
const observed = applyAwaitingOverlayToObserved(hookCodexSessions.applyTo(codexOtel.applyTo(passiveSessionObserver.collect(sessions))))
|
|
4486
|
+
const observed = applyAwaitingOverlayToObserved(hookOpenCodeSessions.applyTo(hookCodexSessions.applyTo(codexOtel.applyTo(passiveSessionObserver.collect(sessions)))))
|
|
4246
4487
|
.map((s) => {
|
|
4247
4488
|
// Steering feedback for observed Claude sessions: devices render
|
|
4248
4489
|
// "stopping at next tool" / queued-directive badges from these.
|
|
@@ -4276,6 +4517,7 @@ export async function startDaemon(opts) {
|
|
|
4276
4517
|
// while these rows are `observed:<agent>:<uuid>`, so normalize before the
|
|
4277
4518
|
// lookup — the two id forms are the standing trap here.
|
|
4278
4519
|
const subagentCensus = subagentTimeline?.summaries() ?? new Map();
|
|
4520
|
+
const coordinationCensus = coordination.summaries();
|
|
4279
4521
|
const enrichedSessions = [...sessions, ...observed, ...remote].map((s) => {
|
|
4280
4522
|
// On-demand review badge (REVIEW tile verdict / REVIEWING state) —
|
|
4281
4523
|
// applies to every session type, managed included.
|
|
@@ -4285,7 +4527,10 @@ export async function startDaemon(opts) {
|
|
|
4285
4527
|
// field that disappears when the last child exits latches "8 running" on
|
|
4286
4528
|
// every client that merges retain-on-absent.
|
|
4287
4529
|
const census = subagentCensus.get(rawSessionId(withReview.id));
|
|
4288
|
-
const
|
|
4530
|
+
const withSubagents = census ? { ...withReview, subagents: census } : withReview;
|
|
4531
|
+
// Same emission rule for the coordination census: zeros once observed.
|
|
4532
|
+
const coord = coordinationCensus.get(rawSessionId(withReview.id));
|
|
4533
|
+
const withCensus = coord ? { ...withSubagents, coordination: coord } : withSubagents;
|
|
4289
4534
|
if (withCensus.elapsedSec != null || !withCensus.startedAt)
|
|
4290
4535
|
return withCensus;
|
|
4291
4536
|
const sec = Math.round((now - Date.parse(withCensus.startedAt)) / 1000);
|
|
@@ -4516,6 +4761,7 @@ export async function startDaemon(opts) {
|
|
|
4516
4761
|
gatewaySessionState = 'idle';
|
|
4517
4762
|
bridgeLogStream.start();
|
|
4518
4763
|
log('[agentdeck] OpenClaw Gateway connected');
|
|
4764
|
+
settleGatewayInstability(Date.now());
|
|
4519
4765
|
if (core.stateMachine.getSnapshot().state === 'disconnected') {
|
|
4520
4766
|
core.stateMachine.handleHookEvent('SessionStart', {});
|
|
4521
4767
|
}
|
|
@@ -4541,6 +4787,11 @@ export async function startDaemon(opts) {
|
|
|
4541
4787
|
log('[agentdeck] OpenClaw Gateway disconnected');
|
|
4542
4788
|
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
4543
4789
|
core.broadcastSessionsList().catch(() => { });
|
|
4790
|
+
const flapAt = Date.now();
|
|
4791
|
+
gatewayFlaps.record(flapAt);
|
|
4792
|
+
assessGatewayInstability(flapAt).catch((err) => {
|
|
4793
|
+
debug('Gateway', `instability assessment failed: ${String(err)}`);
|
|
4794
|
+
});
|
|
4544
4795
|
}
|
|
4545
4796
|
break;
|
|
4546
4797
|
}
|
|
@@ -6254,12 +6505,49 @@ export async function startDaemon(opts) {
|
|
|
6254
6505
|
};
|
|
6255
6506
|
core.broadcast(evalEvent);
|
|
6256
6507
|
});
|
|
6508
|
+
let judgeReprobeInFlight = false;
|
|
6257
6509
|
const apmeEvalTimer = setInterval(() => {
|
|
6258
6510
|
// 1. Enqueue unevaluated runs for deterministic + judge
|
|
6259
6511
|
const pending = apme.store.listUnevaluatedRuns(5);
|
|
6260
6512
|
for (const run of pending) {
|
|
6261
6513
|
apme.runner.enqueue({ runId: run.id, projectPath: run.projectPath ?? undefined });
|
|
6262
6514
|
}
|
|
6515
|
+
// 1b. Drain the TASK judge backlog: closed, unjudged, not declined.
|
|
6516
|
+
// A task's one judge call fires from the collector's close path, so
|
|
6517
|
+
// a daemon restart mid-eval, an offline judge, or a backend probe
|
|
6518
|
+
// not yet ready lost it for good — 1,060 closed tasks had piled up
|
|
6519
|
+
// unjudged (2026-09-03). Batched small (the judge is a local model
|
|
6520
|
+
// answering one prompt at a time) and only while the judge is
|
|
6521
|
+
// actually reachable, so an outage does not turn into a retry storm.
|
|
6522
|
+
// Bounded to 30 days: older rows predate response capture and would
|
|
6523
|
+
// mostly be declined anyway.
|
|
6524
|
+
// One at a time, and only when nothing is already at the judge:
|
|
6525
|
+
// the local backends answer serially, so three per tick queued
|
|
6526
|
+
// past the call timeout, burned every task's attempts, and parked
|
|
6527
|
+
// the whole backlog (289 tasks, 2026-09-03). The live close path
|
|
6528
|
+
// still enqueues on its own; the drain yields to it.
|
|
6529
|
+
const probe = apme.runner.lastBackendProbe;
|
|
6530
|
+
if (probe && probe.status !== 'ready' && Date.now() - probe.checkedAt >= APME_JUDGE_REPROBE_MS && !judgeReprobeInFlight) {
|
|
6531
|
+
judgeReprobeInFlight = true;
|
|
6532
|
+
void apme.runner.refreshBackendProbe(loadApmeConfig().judge).then((status) => {
|
|
6533
|
+
if (status.status === 'ready') {
|
|
6534
|
+
log(`[agentdeck] APME judge ready again: ${status.backend}${status.model ? ` (${status.model})` : ''} — the task backlog resumes`);
|
|
6535
|
+
}
|
|
6536
|
+
}).catch(() => { }).finally(() => { judgeReprobeInFlight = false; });
|
|
6537
|
+
}
|
|
6538
|
+
if (probe?.status === 'ready' && apme.runner.inFlightTaskEvals === 0) {
|
|
6539
|
+
// The backlog query cannot see a park, so ask for a WINDOW and let
|
|
6540
|
+
// `pickBacklogTasks` choose. Asking for one row fed the head of
|
|
6541
|
+
// `ended_at DESC` and nothing else: a task whose judge call fails
|
|
6542
|
+
// every attempt owns that head, and `enqueueTask` drops a parked task
|
|
6543
|
+
// silently, so every tick spent its one slot on it and the backlog
|
|
6544
|
+
// behind it starved (156 tasks, 2026-08-07..23, measured 2026-09-06).
|
|
6545
|
+
// Still one task per tick — the window only decides WHICH one.
|
|
6546
|
+
const backlog = apme.store.listTasksNeedingSummary(APME_TASK_JUDGE_DRAIN_WINDOW, Date.now() - 30 * 86_400_000);
|
|
6547
|
+
for (const t of apme.runner.pickBacklogTasks(backlog, APME_TASK_JUDGE_DRAIN_PER_TICK)) {
|
|
6548
|
+
apme.runner.enqueueTask({ runId: t.runId, taskId: t.id, ...(t.taskCategory ? { category: t.taskCategory } : {}) });
|
|
6549
|
+
}
|
|
6550
|
+
}
|
|
6263
6551
|
// 2. Run outcome detection + composite scoring on recently closed runs
|
|
6264
6552
|
// that don't have an outcome yet.
|
|
6265
6553
|
const closedRuns = apme.store.listRuns({ limit: 20 });
|
|
@@ -6321,26 +6609,29 @@ export async function startDaemon(opts) {
|
|
|
6321
6609
|
// Batched small: `enqueueTask` dispatches immediately with no
|
|
6322
6610
|
// concurrency cap, so a backlog is drained a few per tick rather than
|
|
6323
6611
|
// fired at the judge all at once.
|
|
6612
|
+
// Since `rehydrateOpenRuns` (2026-09-03) a restart no longer feeds
|
|
6613
|
+
// this path — the new daemon adopts the open runs — so what reaches
|
|
6614
|
+
// it is a run nobody resumed: a session bridge that died, or a
|
|
6615
|
+
// daemon that was down for longer than the stale window.
|
|
6324
6616
|
const abandoned = apme.store.listAbandonedRuns(APME_ABANDONED_RUN_STALE_SEC, 5);
|
|
6325
6617
|
for (const run of abandoned) {
|
|
6326
6618
|
if (apme.collector.isLiveRun(run.id))
|
|
6327
6619
|
continue; // still owned by us
|
|
6328
6620
|
const closedTasks = apme.store.reapAbandonedRun(run.id, run.lastActivity);
|
|
6621
|
+
// A run adopted at startup whose session never spoke is reapable
|
|
6622
|
+
// (see `isLiveRun`), and its in-memory edges must go with the row.
|
|
6623
|
+
apme.collector.releaseRun(run.id);
|
|
6329
6624
|
for (const task of closedTasks) {
|
|
6330
|
-
// Judge only what there is something to judge
|
|
6331
|
-
//
|
|
6332
|
-
//
|
|
6333
|
-
//
|
|
6334
|
-
|
|
6335
|
-
// is the data-hygiene win; the eval is a bonus when it can be earned.
|
|
6336
|
-
const hasReply = apme.store.listTurnsForTask(task.id)
|
|
6337
|
-
.some((t) => typeof t.response === 'string' && t.response.trim().length > 0);
|
|
6338
|
-
if (!hasReply)
|
|
6625
|
+
// Judge only what there is something to judge — the one rule in
|
|
6626
|
+
// task-gradeability.ts, which the runner applies again and which
|
|
6627
|
+
// also stamps the reason on the row. Closing the row is the
|
|
6628
|
+
// data-hygiene win; the eval is a bonus when it can be earned.
|
|
6629
|
+
if (!taskGradeability(apme.store.listTurnsForTask(task.id)).gradeable)
|
|
6339
6630
|
continue;
|
|
6340
6631
|
apme.runner.enqueueTask({
|
|
6341
6632
|
runId: run.id, taskId: task.id,
|
|
6342
6633
|
...(task.category ? { category: task.category } : {}),
|
|
6343
|
-
boundarySignal:
|
|
6634
|
+
boundarySignal: task.boundarySignal,
|
|
6344
6635
|
});
|
|
6345
6636
|
}
|
|
6346
6637
|
debug('APME', `reaped abandoned run ${run.id.slice(0, 8)} — ${closedTasks.length} task(s) closed`);
|
|
@@ -6423,6 +6714,10 @@ export async function startDaemon(opts) {
|
|
|
6423
6714
|
// would keep firing self-POSTs at a port this daemon no longer owns.
|
|
6424
6715
|
observedStopWatchdogs.stop();
|
|
6425
6716
|
voiceAssistant?.stop();
|
|
6717
|
+
// A recovery turn in flight is a `claude` child whose own timeout dies with
|
|
6718
|
+
// this process; ending it here is the difference between a bounded 25s
|
|
6719
|
+
// request and an orphan.
|
|
6720
|
+
stopClaudeUsageRecoveryChildren();
|
|
6426
6721
|
bridgeLogStream.stop();
|
|
6427
6722
|
// Flush synchronously — the process exits a few lines below and a pending
|
|
6428
6723
|
// debounce timer would take the last turn's entries with it.
|