@agentdeck/bridge 1.0.24 → 1.1.0

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.
Files changed (117) hide show
  1. package/dist/adapters/openclaw.d.ts +74 -4
  2. package/dist/adapters/openclaw.d.ts.map +1 -1
  3. package/dist/adapters/openclaw.js +235 -43
  4. package/dist/adapters/openclaw.js.map +1 -1
  5. package/dist/apme/adapters/openclaw-hook.d.ts +38 -10
  6. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
  7. package/dist/apme/adapters/openclaw-hook.js +181 -37
  8. package/dist/apme/adapters/openclaw-hook.js.map +1 -1
  9. package/dist/apme/collector.d.ts +37 -1
  10. package/dist/apme/collector.d.ts.map +1 -1
  11. package/dist/apme/collector.js +277 -35
  12. package/dist/apme/collector.js.map +1 -1
  13. package/dist/apme/pareto.d.ts +2 -1
  14. package/dist/apme/pareto.d.ts.map +1 -1
  15. package/dist/apme/pareto.js +15 -4
  16. package/dist/apme/pareto.js.map +1 -1
  17. package/dist/apme/recommend.d.ts +24 -1
  18. package/dist/apme/recommend.d.ts.map +1 -1
  19. package/dist/apme/recommend.js +43 -7
  20. package/dist/apme/recommend.js.map +1 -1
  21. package/dist/apme/store.d.ts +6 -0
  22. package/dist/apme/store.d.ts.map +1 -1
  23. package/dist/apme/store.js +244 -72
  24. package/dist/apme/store.js.map +1 -1
  25. package/dist/bridge-core.d.ts +10 -1
  26. package/dist/bridge-core.d.ts.map +1 -1
  27. package/dist/bridge-core.js +20 -5
  28. package/dist/bridge-core.js.map +1 -1
  29. package/dist/card-feed.d.ts +46 -1
  30. package/dist/card-feed.d.ts.map +1 -1
  31. package/dist/card-feed.js +166 -6
  32. package/dist/card-feed.js.map +1 -1
  33. package/dist/cli.d.ts +45 -0
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +388 -27
  36. package/dist/cli.js.map +1 -1
  37. package/dist/daemon-build-identity.d.ts +124 -0
  38. package/dist/daemon-build-identity.d.ts.map +1 -0
  39. package/dist/daemon-build-identity.js +310 -0
  40. package/dist/daemon-build-identity.js.map +1 -0
  41. package/dist/daemon-server.d.ts +34 -6
  42. package/dist/daemon-server.d.ts.map +1 -1
  43. package/dist/daemon-server.js +613 -108
  44. package/dist/daemon-server.js.map +1 -1
  45. package/dist/daemon-takeover.d.ts +11 -0
  46. package/dist/daemon-takeover.d.ts.map +1 -1
  47. package/dist/daemon-takeover.js +62 -17
  48. package/dist/daemon-takeover.js.map +1 -1
  49. package/dist/daemon.js +1 -1
  50. package/dist/daemon.js.map +1 -1
  51. package/dist/esp32-serial.d.ts +14 -0
  52. package/dist/esp32-serial.d.ts.map +1 -1
  53. package/dist/esp32-serial.js +19 -1
  54. package/dist/esp32-serial.js.map +1 -1
  55. package/dist/glance-frame.d.ts.map +1 -1
  56. package/dist/glance-frame.js +3 -1
  57. package/dist/glance-frame.js.map +1 -1
  58. package/dist/hook-codex-sessions.d.ts +3 -1
  59. package/dist/hook-codex-sessions.d.ts.map +1 -1
  60. package/dist/hook-codex-sessions.js +27 -7
  61. package/dist/hook-codex-sessions.js.map +1 -1
  62. package/dist/legacy-rearm-ledger.d.ts +56 -0
  63. package/dist/legacy-rearm-ledger.d.ts.map +1 -0
  64. package/dist/legacy-rearm-ledger.js +66 -0
  65. package/dist/legacy-rearm-ledger.js.map +1 -0
  66. package/dist/mdns.d.ts +33 -0
  67. package/dist/mdns.d.ts.map +1 -1
  68. package/dist/mdns.js +91 -2
  69. package/dist/mdns.js.map +1 -1
  70. package/dist/openclaw-session.d.ts.map +1 -1
  71. package/dist/openclaw-session.js +13 -0
  72. package/dist/openclaw-session.js.map +1 -1
  73. package/dist/openclaw-timeline-feed.d.ts +84 -0
  74. package/dist/openclaw-timeline-feed.d.ts.map +1 -0
  75. package/dist/openclaw-timeline-feed.js +162 -0
  76. package/dist/openclaw-timeline-feed.js.map +1 -0
  77. package/dist/openclaw-transcript-timeline.d.ts +103 -0
  78. package/dist/openclaw-transcript-timeline.d.ts.map +1 -0
  79. package/dist/openclaw-transcript-timeline.js +328 -0
  80. package/dist/openclaw-transcript-timeline.js.map +1 -0
  81. package/dist/passive-observer.d.ts +21 -1
  82. package/dist/passive-observer.d.ts.map +1 -1
  83. package/dist/passive-observer.js +37 -3
  84. package/dist/passive-observer.js.map +1 -1
  85. package/dist/relayed-usage.d.ts +68 -0
  86. package/dist/relayed-usage.d.ts.map +1 -0
  87. package/dist/relayed-usage.js +80 -0
  88. package/dist/relayed-usage.js.map +1 -0
  89. package/dist/session-aggregator.d.ts +3 -0
  90. package/dist/session-aggregator.d.ts.map +1 -1
  91. package/dist/session-aggregator.js.map +1 -1
  92. package/dist/session-registry.d.ts +1 -0
  93. package/dist/session-registry.d.ts.map +1 -1
  94. package/dist/surface-protocol.d.ts +84 -0
  95. package/dist/surface-protocol.d.ts.map +1 -0
  96. package/dist/surface-protocol.js +235 -0
  97. package/dist/surface-protocol.js.map +1 -0
  98. package/dist/timeline-store.d.ts +1 -1
  99. package/dist/timeline-store.d.ts.map +1 -1
  100. package/dist/timeline-store.js +16 -1
  101. package/dist/timeline-store.js.map +1 -1
  102. package/dist/tui/renderer.d.ts.map +1 -1
  103. package/dist/tui/renderer.js +35 -0
  104. package/dist/tui/renderer.js.map +1 -1
  105. package/dist/weather-config.d.ts +17 -0
  106. package/dist/weather-config.d.ts.map +1 -0
  107. package/dist/weather-config.js +54 -0
  108. package/dist/weather-config.js.map +1 -0
  109. package/dist/weather.d.ts +44 -4
  110. package/dist/weather.d.ts.map +1 -1
  111. package/dist/weather.js +223 -15
  112. package/dist/weather.js.map +1 -1
  113. package/dist/ws-server.d.ts.map +1 -1
  114. package/dist/ws-server.js +55 -5
  115. package/dist/ws-server.js.map +1 -1
  116. package/fm-helper/AgentDeckFMHelper.swift +51 -5
  117. package/package.json +3 -3
@@ -58,10 +58,29 @@ export declare class OpenClawAdapter extends EventEmitter implements AgentAdapte
58
58
  private apmeSessionId;
59
59
  private apmeTraceId;
60
60
  private apmeCwdHint;
61
- /** Idle-gap timer fires the `task_boundary` (idle_gap) span when the
62
- * user hasn't sent a new `chat.send` for OPENCLAW_IDLE_GAP_MS after the
63
- * last `chat.final`. Reset on every new send, cleared on session end. */
64
- private apmeIdleTimer;
61
+ /** Opens (or returns) the APME run for one Gateway session key. Supplied by
62
+ * the daemon, which owns the collector. */
63
+ private apmeRunFor;
64
+ /** Per-Gateway-session-key APME state.
65
+ *
66
+ * **Why this is keyed by session key and not by connection.** One Gateway
67
+ * connection sees every session the agent has: the chat the user is looking
68
+ * at (`agent:main:main`), each cron job (`agent:main:cron:<id>`), and one
69
+ * fresh key per model-eval run (`agent:main:eval-…__r2` — a suite opens six
70
+ * in three hours). A run scoped to the connection therefore interleaves all
71
+ * of them into one trajectory under one `model_id`, and the idle-gap
72
+ * boundary is computed across traffic from conversations that have nothing
73
+ * to do with each other. Turn ordering, per-task cost and every judge
74
+ * verdict derived from them describe a conversation that never happened.
75
+ *
76
+ * Each entry owns its own idle-gap timer for the same reason: a cron
77
+ * heartbeat firing every few minutes would otherwise keep resetting the
78
+ * timer for a user chat that ended half an hour ago, and that task would
79
+ * never close — a task that never closes is never evaluated. */
80
+ private apmeBySessionKey;
81
+ /** Idle-gap timer for frames that arrive without a session key (the
82
+ * connection-scoped fallback run). */
83
+ private apmeFallbackIdleTimer;
65
84
  /** Reconnect delay (doubles on each failure, max 30s) */
66
85
  private reconnectDelay;
67
86
  private static readonly MAX_RECONNECT_DELAY;
@@ -70,17 +89,52 @@ export declare class OpenClawAdapter extends EventEmitter implements AgentAdapte
70
89
  /** Bind the adapter to an APME session id so Gateway events can be
71
90
  * ingested as TelemetrySpans. No-op when APME isn't initialized. */
72
91
  setApmeSession(sessionId: string, cwd?: string): void;
92
+ /** Install the per-session-key run opener. The daemon owns the collector, so
93
+ * the adapter asks it for the APME session id belonging to a Gateway
94
+ * session key and caches the answer. Returning `null` (APME disabled, or
95
+ * `openRun` threw) makes that key fall back to the connection-scoped id,
96
+ * which is the pre-split behaviour rather than silence. */
97
+ setApmeRunResolver(resolve: (sessionKey: string) => string | null): void;
98
+ /** APME session ids this adapter has opened, so the daemon can close them
99
+ * all when the Gateway link drops. */
100
+ apmeSessionIds(): string[];
73
101
  /** True when this adapter pipes Gateway events into the APME collector
74
102
  * itself — the bridge must then NOT also convert its timeline entries to
75
103
  * spans, or every turn and tool would be counted twice. */
76
104
  hasDirectApmeIngestion(): boolean;
105
+ /** Resolve the APME context for a Gateway session key.
106
+ *
107
+ * A frame with no session key falls back to the connection-scoped id — that
108
+ * is what the adapter had before the split, and dropping such a frame would
109
+ * trade a mis-scoped record for no record at all. */
77
110
  private buildApmeCtx;
111
+ /** Get-or-create the per-session-key entry — WITHOUT opening its APME run.
112
+ *
113
+ * Opening the run here is what a first version did, and it manufactured
114
+ * exactly the noise this whole split exists to remove: a context is built
115
+ * for every frame, including ones that parse to zero spans, so a real
116
+ * `agent:main:main:heartbeat` key opened a run holding 0 turns, 0 steps and
117
+ * 0 events (measured on live traffic 2026-08-23). The Gateway filters
118
+ * heartbeat message pairs out of `session.message`, so such a key can emit
119
+ * frames that carry nothing to record. A run is opened on the first span
120
+ * that is actually ingested. */
121
+ private apmeEntryFor;
122
+ /** The APME session id for a key, opening its run on first use. */
123
+ private apmeSessionIdFor;
124
+ /** Ingest spans against the run belonging to `sessionKey`.
125
+ *
126
+ * The target must be re-resolved from the key, never read off
127
+ * `this.apmeSessionId` — reading that field here is what would silently
128
+ * re-collapse every per-key run back into one. Returns early on an empty
129
+ * span list so no run is opened for a frame that records nothing. */
78
130
  private ingestApmeSpans;
79
131
  /** Reset (or start) the idle-gap timer. Fires
80
132
  * `openclawIdleGapTaskBoundary` after OPENCLAW_IDLE_GAP_MS of silence
81
133
  * (no new `chat.send`) following the last `chat.final`. */
82
134
  private armIdleGapTimer;
83
135
  private clearIdleGapTimer;
136
+ /** Clear every per-key timer (shutdown). */
137
+ private clearAllIdleGapTimers;
84
138
  start(options: AdapterStartOptions & {
85
139
  externalServer?: Server;
86
140
  }): Promise<void>;
@@ -162,6 +216,22 @@ export declare class OpenClawAdapter extends EventEmitter implements AgentAdapte
162
216
  * Called when Gateway sends `connect.challenge` event with nonce.
163
217
  */
164
218
  private sendConnectRequest;
219
+ /** Join the Gateway's session event stream (`sessions.subscribe`, scope
220
+ * `operator.read`).
221
+ *
222
+ * The Gateway's recipient set for `session.message` is
223
+ * `sessionEventSubscribers ∪ sessionMessageSubscribers`, and the former is
224
+ * unioned in unconditionally — so this one no-param call delivers the
225
+ * messages of EVERY session, which is what we want: AgentDeck observes the
226
+ * whole agent, not one chat. `sessions.messages.subscribe` is the per-key
227
+ * alternative and would need a subscribe/unsubscribe cycle for every
228
+ * session key the store mints (an eval suite opens a fresh one per run), so
229
+ * it is the wrong shape here.
230
+ *
231
+ * Failure is non-fatal and stays visible: without the subscription the
232
+ * adapter degrades to exactly its previous behaviour (chat + approvals
233
+ * only), which is a smaller timeline, not a broken one. */
234
+ private subscribeSessionEvents;
165
235
  /** Fetch sessions and set the most recently updated one as active. */
166
236
  private fetchSessions;
167
237
  /** Fetch model catalog via CLI and emit events. Retries once on failure. */
@@ -1 +1 @@
1
- {"version":3,"file":"openclaw.d.ts","sourceRoot":"","sources":["../../src/adapters/openclaw.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"openclaw.d.ts","sourceRoot":"","sources":["../../src/adapters/openclaw.ts"],"names":[],"mappings":"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"}
@@ -94,10 +94,29 @@ export class OpenClawAdapter extends EventEmitter {
94
94
  apmeSessionId = null;
95
95
  apmeTraceId = randomUUID();
96
96
  apmeCwdHint;
97
- /** Idle-gap timer fires the `task_boundary` (idle_gap) span when the
98
- * user hasn't sent a new `chat.send` for OPENCLAW_IDLE_GAP_MS after the
99
- * last `chat.final`. Reset on every new send, cleared on session end. */
100
- apmeIdleTimer = null;
97
+ /** Opens (or returns) the APME run for one Gateway session key. Supplied by
98
+ * the daemon, which owns the collector. */
99
+ apmeRunFor = null;
100
+ /** Per-Gateway-session-key APME state.
101
+ *
102
+ * **Why this is keyed by session key and not by connection.** One Gateway
103
+ * connection sees every session the agent has: the chat the user is looking
104
+ * at (`agent:main:main`), each cron job (`agent:main:cron:<id>`), and one
105
+ * fresh key per model-eval run (`agent:main:eval-…__r2` — a suite opens six
106
+ * in three hours). A run scoped to the connection therefore interleaves all
107
+ * of them into one trajectory under one `model_id`, and the idle-gap
108
+ * boundary is computed across traffic from conversations that have nothing
109
+ * to do with each other. Turn ordering, per-task cost and every judge
110
+ * verdict derived from them describe a conversation that never happened.
111
+ *
112
+ * Each entry owns its own idle-gap timer for the same reason: a cron
113
+ * heartbeat firing every few minutes would otherwise keep resetting the
114
+ * timer for a user chat that ended half an hour ago, and that task would
115
+ * never close — a task that never closes is never evaluated. */
116
+ apmeBySessionKey = new Map();
117
+ /** Idle-gap timer for frames that arrive without a session key (the
118
+ * connection-scoped fallback run). */
119
+ apmeFallbackIdleTimer = null;
101
120
  /** Reconnect delay (doubles on each failure, max 30s) */
102
121
  reconnectDelay = 1000;
103
122
  static MAX_RECONNECT_DELAY = 30_000;
@@ -155,30 +174,103 @@ export class OpenClawAdapter extends EventEmitter {
155
174
  this.apmeSessionId = sessionId;
156
175
  this.apmeCwdHint = cwd;
157
176
  }
177
+ /** Install the per-session-key run opener. The daemon owns the collector, so
178
+ * the adapter asks it for the APME session id belonging to a Gateway
179
+ * session key and caches the answer. Returning `null` (APME disabled, or
180
+ * `openRun` threw) makes that key fall back to the connection-scoped id,
181
+ * which is the pre-split behaviour rather than silence. */
182
+ setApmeRunResolver(resolve) {
183
+ this.apmeRunFor = resolve;
184
+ }
185
+ /** APME session ids this adapter has opened, so the daemon can close them
186
+ * all when the Gateway link drops. */
187
+ apmeSessionIds() {
188
+ return [...this.apmeBySessionKey.values()]
189
+ .map((e) => e.sessionId)
190
+ .filter((id) => id !== null);
191
+ }
158
192
  /** True when this adapter pipes Gateway events into the APME collector
159
193
  * itself — the bridge must then NOT also convert its timeline entries to
160
194
  * spans, or every turn and tool would be counted twice. */
161
195
  hasDirectApmeIngestion() {
162
196
  return this.apmeSessionId != null && getApme() != null;
163
197
  }
164
- buildApmeCtx() {
165
- if (!this.apmeSessionId || !getApme())
198
+ /** Resolve the APME context for a Gateway session key.
199
+ *
200
+ * A frame with no session key falls back to the connection-scoped id — that
201
+ * is what the adapter had before the split, and dropping such a frame would
202
+ * trade a mis-scoped record for no record at all. */
203
+ buildApmeCtx(sessionKey) {
204
+ if (!getApme())
205
+ return null;
206
+ const entry = sessionKey ? this.apmeEntryFor(sessionKey) : null;
207
+ // `sessionId` here is provisional: for a keyed context the run may not be
208
+ // open yet (see `apmeEntryFor`), and `ingestApmeSpans` re-resolves it
209
+ // against `sessionKey` before writing anything. It is never read into a
210
+ // span attribute — only used to route the ingest.
211
+ const sessionId = entry?.sessionId ?? this.apmeSessionId;
212
+ if (!sessionId && !entry)
166
213
  return null;
167
214
  return {
168
- sessionId: this.apmeSessionId,
215
+ sessionId: sessionId ?? '',
169
216
  agentType: 'openclaw',
170
217
  cwd: this.apmeCwdHint,
171
- traceId: this.apmeTraceId,
218
+ traceId: entry?.traceId ?? this.apmeTraceId,
172
219
  activeTurnId: undefined,
173
220
  };
174
221
  }
175
- ingestApmeSpans(spans) {
222
+ /** Get-or-create the per-session-key entry — WITHOUT opening its APME run.
223
+ *
224
+ * Opening the run here is what a first version did, and it manufactured
225
+ * exactly the noise this whole split exists to remove: a context is built
226
+ * for every frame, including ones that parse to zero spans, so a real
227
+ * `agent:main:main:heartbeat` key opened a run holding 0 turns, 0 steps and
228
+ * 0 events (measured on live traffic 2026-08-23). The Gateway filters
229
+ * heartbeat message pairs out of `session.message`, so such a key can emit
230
+ * frames that carry nothing to record. A run is opened on the first span
231
+ * that is actually ingested. */
232
+ apmeEntryFor(sessionKey) {
233
+ const existing = this.apmeBySessionKey.get(sessionKey);
234
+ if (existing)
235
+ return existing;
236
+ if (!this.apmeRunFor)
237
+ return null;
238
+ const entry = { sessionId: null, traceId: randomUUID(), idleTimer: null };
239
+ this.apmeBySessionKey.set(sessionKey, entry);
240
+ return entry;
241
+ }
242
+ /** The APME session id for a key, opening its run on first use. */
243
+ apmeSessionIdFor(sessionKey) {
244
+ const entry = this.apmeEntryFor(sessionKey);
245
+ if (!entry)
246
+ return null;
247
+ if (entry.sessionId)
248
+ return entry.sessionId;
249
+ const sessionId = this.apmeRunFor?.(sessionKey) ?? null;
250
+ if (!sessionId)
251
+ return null;
252
+ entry.sessionId = sessionId;
253
+ debug('adapter:openclaw', `APME run opened for ${sessionKey} → ${sessionId.slice(0, 16)}`);
254
+ return sessionId;
255
+ }
256
+ /** Ingest spans against the run belonging to `sessionKey`.
257
+ *
258
+ * The target must be re-resolved from the key, never read off
259
+ * `this.apmeSessionId` — reading that field here is what would silently
260
+ * re-collapse every per-key run back into one. Returns early on an empty
261
+ * span list so no run is opened for a frame that records nothing. */
262
+ ingestApmeSpans(sessionKey, spans) {
176
263
  const apme = getApme();
177
- if (!apme || !this.apmeSessionId || spans.length === 0)
264
+ if (!apme || spans.length === 0)
265
+ return;
266
+ const target = sessionKey
267
+ ? this.apmeSessionIdFor(sessionKey) ?? this.apmeSessionId
268
+ : this.apmeSessionId;
269
+ if (!target)
178
270
  return;
179
271
  for (const span of spans) {
180
272
  try {
181
- apme.collector.ingestSpan(this.apmeSessionId, span);
273
+ apme.collector.ingestSpan(target, span);
182
274
  }
183
275
  catch (err) {
184
276
  debug('apme:openclaw', `ingestSpan failed: ${String(err)}`);
@@ -188,19 +280,29 @@ export class OpenClawAdapter extends EventEmitter {
188
280
  /** Reset (or start) the idle-gap timer. Fires
189
281
  * `openclawIdleGapTaskBoundary` after OPENCLAW_IDLE_GAP_MS of silence
190
282
  * (no new `chat.send`) following the last `chat.final`. */
191
- armIdleGapTimer() {
192
- this.clearIdleGapTimer();
193
- const ctx = this.buildApmeCtx();
283
+ armIdleGapTimer(sessionKey) {
284
+ this.clearIdleGapTimer(sessionKey);
285
+ const ctx = this.buildApmeCtx(sessionKey);
194
286
  if (!ctx)
195
287
  return;
196
- this.apmeIdleTimer = setTimeout(() => {
197
- this.apmeIdleTimer = null;
288
+ const holder = sessionKey ? this.apmeBySessionKey.get(sessionKey) : null;
289
+ const timer = setTimeout(() => {
290
+ if (holder)
291
+ holder.idleTimer = null;
292
+ else
293
+ this.apmeFallbackIdleTimer = null;
198
294
  const apme = getApme();
199
- if (!apme || !this.apmeSessionId)
295
+ if (!apme)
296
+ return;
297
+ // Resolve at FIRE time, and never open a run here: a boundary closing a
298
+ // task that was never opened would mint an empty run at the exact moment
299
+ // the conversation went quiet.
300
+ const target = holder?.sessionId ?? (sessionKey ? null : this.apmeSessionId);
301
+ if (!target)
200
302
  return;
201
303
  const span = openclawIdleGapTaskBoundary(ctx);
202
304
  try {
203
- apme.collector.ingestSpan(this.apmeSessionId, span);
305
+ apme.collector.ingestSpan(target, span);
204
306
  }
205
307
  catch (err) {
206
308
  debug('apme:openclaw', `idle_gap ingestSpan failed: ${String(err)}`);
@@ -208,14 +310,35 @@ export class OpenClawAdapter extends EventEmitter {
208
310
  }, OPENCLAW_IDLE_GAP_MS);
209
311
  // Don't keep the event loop alive just for this timer; the adapter is
210
312
  // already pinning the loop via WebSocket.
211
- this.apmeIdleTimer.unref?.();
313
+ timer.unref?.();
314
+ if (holder)
315
+ holder.idleTimer = timer;
316
+ else
317
+ this.apmeFallbackIdleTimer = timer;
212
318
  }
213
- clearIdleGapTimer() {
214
- if (this.apmeIdleTimer) {
215
- clearTimeout(this.apmeIdleTimer);
216
- this.apmeIdleTimer = null;
319
+ clearIdleGapTimer(sessionKey) {
320
+ const holder = sessionKey ? this.apmeBySessionKey.get(sessionKey) : null;
321
+ if (holder) {
322
+ if (holder.idleTimer)
323
+ clearTimeout(holder.idleTimer);
324
+ holder.idleTimer = null;
325
+ return;
326
+ }
327
+ if (this.apmeFallbackIdleTimer) {
328
+ clearTimeout(this.apmeFallbackIdleTimer);
329
+ this.apmeFallbackIdleTimer = null;
217
330
  }
218
331
  }
332
+ /** Clear every per-key timer (shutdown). */
333
+ clearAllIdleGapTimers() {
334
+ for (const entry of this.apmeBySessionKey.values()) {
335
+ if (entry.idleTimer)
336
+ clearTimeout(entry.idleTimer);
337
+ entry.idleTimer = null;
338
+ }
339
+ // ...and the keyless fallback timer.
340
+ this.clearIdleGapTimer();
341
+ }
219
342
  async start(options) {
220
343
  if (options.gatewayUrl) {
221
344
  this.gatewayUrl = options.gatewayUrl;
@@ -290,7 +413,16 @@ export class OpenClawAdapter extends EventEmitter {
290
413
  }
291
414
  setPendingApproval(prompt) {
292
415
  this.clearPendingApproval();
293
- this.pendingApproval = prompt;
416
+ // Which OpenClaw session asked is the single most orienting fact about an
417
+ // approval — `agent:main:main` (the chat the user is looking at) vs
418
+ // `agent:main:cron:<id>` vs `agent:main:eval-…__r2` are wildly different
419
+ // things to approve, and the deck row is pinned to the literal "OpenClaw"
420
+ // for all of them. The Gateway does not always put `sessionKey` on the
421
+ // approval body, but the adapter has been tracking it off the chat stream
422
+ // all along, so fall back to that rather than surfacing nothing.
423
+ this.pendingApproval = prompt.sessionKey || !this.currentSessionKey
424
+ ? prompt
425
+ : { ...prompt, sessionKey: this.currentSessionKey };
294
426
  if (typeof prompt.expiresAtMs === 'number') {
295
427
  const delay = prompt.expiresAtMs - Date.now();
296
428
  // An already-expired stamp still needs the sweep to run (on the next
@@ -319,7 +451,12 @@ export class OpenClawAdapter extends EventEmitter {
319
451
  this.clearPendingApproval();
320
452
  this.emitTimelineEntry({
321
453
  ts: Date.now(), type: 'tool_resolved', raw: `Not approved · ${reason}`,
322
- approvalId, status: 'denied',
454
+ // NOT `denied` — nobody refused this. It expired, its run was cancelled,
455
+ // its turn ended, or the link dropped. Writing `denied` here made a user's
456
+ // deliberate refusal byte-identical to an approval that was never
457
+ // answered, which is the commoner case by far and the one that means the
458
+ // prompt failed to reach a human in time. `raw` names which of the four.
459
+ approvalId, status: 'abandoned',
323
460
  });
324
461
  this.emitAdapterEvent({ source: 'parser', event: 'idle' });
325
462
  }
@@ -453,10 +590,10 @@ export class OpenClawAdapter extends EventEmitter {
453
590
  // APME: open a turn. The matching `turn_response` lands on
454
591
  // `chat.final` below. Cancel any pending idle-gap timer — a new
455
592
  // send means the conversation is still active.
456
- const ctx = this.buildApmeCtx();
593
+ const ctx = this.buildApmeCtx(this.currentSessionKey);
457
594
  if (ctx) {
458
- this.clearIdleGapTimer();
459
- this.ingestApmeSpans([openclawChatSendToSpan(ctx, cmd.text)]);
595
+ this.clearIdleGapTimer(this.currentSessionKey);
596
+ this.ingestApmeSpans(this.currentSessionKey, [openclawChatSendToSpan(ctx, cmd.text)]);
460
597
  }
461
598
  this.rpcCall('chat.send', {
462
599
  sessionKey: this.currentSessionKey,
@@ -560,7 +697,7 @@ export class OpenClawAdapter extends EventEmitter {
560
697
  this.reconnectTimer = null;
561
698
  }
562
699
  // Cancel any pending idle-gap timer so it doesn't fire after shutdown.
563
- this.clearIdleGapTimer();
700
+ this.clearAllIdleGapTimers();
564
701
  for (const [id, pending] of this.pendingRpc) {
565
702
  pending.reject(new Error('Adapter shutting down'));
566
703
  this.pendingRpc.delete(id);
@@ -985,7 +1122,7 @@ export class OpenClawAdapter extends EventEmitter {
985
1122
  // OPENCLAW_IDLE_GAP_MS the timer is cancelled (above in
986
1123
  // `send_prompt`); otherwise it fires `task_boundary` (idle_gap)
987
1124
  // and closes the task.
988
- const apmeCtx = this.buildApmeCtx();
1125
+ const apmeCtx = this.buildApmeCtx(sessionKey);
989
1126
  if (apmeCtx) {
990
1127
  const apmePayload = {
991
1128
  state: 'final',
@@ -994,8 +1131,8 @@ export class OpenClawAdapter extends EventEmitter {
994
1131
  ...(responseContent ? { response: responseContent } : {}),
995
1132
  ...(Array.isArray(payload.tools) ? { tools: payload.tools } : {}),
996
1133
  };
997
- this.ingestApmeSpans(openclawChatEventToSpans(apmeCtx, apmePayload));
998
- this.armIdleGapTimer();
1134
+ this.ingestApmeSpans(sessionKey, openclawChatEventToSpans(apmeCtx, apmePayload));
1135
+ this.armIdleGapTimer(sessionKey);
999
1136
  }
1000
1137
  this.chatStarted = false;
1001
1138
  this.lastPrompt = null;
@@ -1022,10 +1159,10 @@ export class OpenClawAdapter extends EventEmitter {
1022
1159
  // APME: emit task_boundary so the abort itself closes the task
1023
1160
  // immediately \u2014 don't wait for the idle timer. The user
1024
1161
  // explicitly stopped this turn.
1025
- const abortCtx = this.buildApmeCtx();
1162
+ const abortCtx = this.buildApmeCtx(sessionKey);
1026
1163
  if (abortCtx) {
1027
- this.clearIdleGapTimer();
1028
- this.ingestApmeSpans(openclawChatEventToSpans(abortCtx, {
1164
+ this.clearIdleGapTimer(sessionKey);
1165
+ this.ingestApmeSpans(sessionKey, openclawChatEventToSpans(abortCtx, {
1029
1166
  state: 'aborted',
1030
1167
  ...(payload.runId ? { runId: payload.runId } : {}),
1031
1168
  ...(payload.sessionKey ? { sessionKey: payload.sessionKey } : {}),
@@ -1082,10 +1219,10 @@ export class OpenClawAdapter extends EventEmitter {
1082
1219
  // idle-gap timer. `chat.send` cleared that timer and only `final`
1083
1220
  // used to re-arm it, so a prompt that errored and was then
1084
1221
  // abandoned left its task open indefinitely.
1085
- const errCtx = this.buildApmeCtx();
1222
+ const errCtx = this.buildApmeCtx(sessionKey);
1086
1223
  if (errCtx) {
1087
- this.ingestApmeSpans(openclawChatEventToSpans(errCtx, errInput));
1088
- this.armIdleGapTimer();
1224
+ this.ingestApmeSpans(sessionKey, openclawChatEventToSpans(errCtx, errInput));
1225
+ this.armIdleGapTimer(sessionKey);
1089
1226
  }
1090
1227
  this.chatStarted = false;
1091
1228
  this.lastPrompt = null;
@@ -1198,15 +1335,27 @@ export class OpenClawAdapter extends EventEmitter {
1198
1335
  // trajectory empty of detail. Route them into the APME normalizer so the
1199
1336
  // SessionSample captures the full tool-use trajectory (req #6).
1200
1337
  case 'session.tool': {
1201
- const apmeCtx = this.buildApmeCtx();
1338
+ const toolPayload = payload;
1339
+ const apmeCtx = this.buildApmeCtx(toolPayload.sessionKey);
1202
1340
  if (apmeCtx)
1203
- this.ingestApmeSpans(openclawSessionToolToSpans(apmeCtx, payload));
1341
+ this.ingestApmeSpans(toolPayload.sessionKey, openclawSessionToolToSpans(apmeCtx, toolPayload));
1204
1342
  break;
1205
1343
  }
1206
1344
  case 'session.message': {
1207
- const apmeCtx = this.buildApmeCtx();
1208
- if (apmeCtx)
1209
- this.ingestApmeSpans(openclawSessionMessageToSpans(apmeCtx, payload));
1345
+ const msgPayload = payload;
1346
+ const apmeCtx = this.buildApmeCtx(msgPayload.sessionKey);
1347
+ if (!apmeCtx)
1348
+ break;
1349
+ this.ingestApmeSpans(msgPayload.sessionKey, openclawSessionMessageToSpans(apmeCtx, msgPayload));
1350
+ // A user message re-opens the conversation: cancel the pending
1351
+ // idle-gap boundary for THIS key so a follow-up question stays in the
1352
+ // same task. An assistant message means the turn just landed, so the
1353
+ // gap starts counting from here.
1354
+ const role = (msgPayload.message?.role ?? '').toLowerCase();
1355
+ if (role === 'user')
1356
+ this.clearIdleGapTimer(msgPayload.sessionKey);
1357
+ else if (role === 'assistant')
1358
+ this.armIdleGapTimer(msgPayload.sessionKey);
1210
1359
  break;
1211
1360
  }
1212
1361
  default:
@@ -1289,6 +1438,21 @@ export class OpenClawAdapter extends EventEmitter {
1289
1438
  this.fetchSessions().catch(err => debug('adapter:openclaw', `fetchSessions error: ${err}`));
1290
1439
  // Fetch model catalog (async, non-blocking)
1291
1440
  this.emitModelCatalog().catch(err => debug('adapter:openclaw', `emitModelCatalog error: ${err}`));
1441
+ // Subscribe to the session event stream. Without this call the
1442
+ // Gateway puts us in NEITHER `sessionEventSubscribers` nor
1443
+ // `sessionMessageSubscribers`, and those two sets are the entire
1444
+ // recipient list for `session.message` and `session.tool` — so the
1445
+ // handlers for both events below have existed since they were written
1446
+ // without ever receiving one frame. That is what left APME with 657 of
1447
+ // 665 OpenClaw runs holding zero turns: the `chat` frame carries only
1448
+ // the ASSISTANT message, so nothing ever opened a turn for a
1449
+ // conversation the user started in the OpenClaw app, and a
1450
+ // `turn_response` with no open turn is dropped on the floor
1451
+ // (`Collector.setTurnResponse` → `if (!turnId) return`).
1452
+ //
1453
+ // `operator.read` is already in our requested scopes (it is what
1454
+ // `sessions.list` runs under), so this needs no new permission.
1455
+ this.subscribeSessionEvents().catch(err => debug('adapter:openclaw', `subscribeSessionEvents error: ${err}`));
1292
1456
  // Catch up on approvals that were already waiting. `exec.approval.requested`
1293
1457
  // is a broadcast and is never replayed, so without this read a daemon
1294
1458
  // restart (or any Gateway reconnect) leaves the agent blocked on an
@@ -1318,6 +1482,34 @@ export class OpenClawAdapter extends EventEmitter {
1318
1482
  debug('adapter:openclaw', `Failed to send connect request: ${err}`);
1319
1483
  }
1320
1484
  }
1485
+ /** Join the Gateway's session event stream (`sessions.subscribe`, scope
1486
+ * `operator.read`).
1487
+ *
1488
+ * The Gateway's recipient set for `session.message` is
1489
+ * `sessionEventSubscribers ∪ sessionMessageSubscribers`, and the former is
1490
+ * unioned in unconditionally — so this one no-param call delivers the
1491
+ * messages of EVERY session, which is what we want: AgentDeck observes the
1492
+ * whole agent, not one chat. `sessions.messages.subscribe` is the per-key
1493
+ * alternative and would need a subscribe/unsubscribe cycle for every
1494
+ * session key the store mints (an eval suite opens a fresh one per run), so
1495
+ * it is the wrong shape here.
1496
+ *
1497
+ * Failure is non-fatal and stays visible: without the subscription the
1498
+ * adapter degrades to exactly its previous behaviour (chat + approvals
1499
+ * only), which is a smaller timeline, not a broken one. */
1500
+ async subscribeSessionEvents() {
1501
+ const result = await this.rpcCall('sessions.subscribe', {});
1502
+ const subscribed = !!(result && typeof result === 'object'
1503
+ && result.subscribed);
1504
+ if (!subscribed) {
1505
+ // The Gateway answers `{subscribed:false}` when the connection has no
1506
+ // `connId` — a real refusal, not a transport error, so it never lands in
1507
+ // the catch above.
1508
+ logError('[adapter:openclaw] sessions.subscribe returned subscribed:false — session.message/session.tool will not arrive');
1509
+ return;
1510
+ }
1511
+ debug('adapter:openclaw', 'sessions.subscribe ok');
1512
+ }
1321
1513
  /** Fetch sessions and set the most recently updated one as active. */
1322
1514
  async fetchSessions() {
1323
1515
  try {