@beeos-ai/device-agent 0.5.1 → 0.6.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.
@@ -6,27 +6,56 @@
6
6
  * `session/update` envelopes are produced by translating `AgentEvent`s
7
7
  * received from the host bus.
8
8
  *
9
- * SessionManager itself is protocol-agnostic w.r.t. the agent core — it
10
- * speaks `AgentHost` only. The translation of host events into ACP
11
- * envelopes lives here so swapping `IAgentCore` (single-layer / pi
12
- * orchestrator / mock) doesn't change ACP semantics.
9
+ * Persistence model — turn-grained:
13
10
  *
14
- * Surface mirrors the Python `SessionHandler` so ACP handlers remain
15
- * thin pass-throughs.
11
+ * - User prompts persist immediately on entry to `prompt()` (one
12
+ * `user_message` JSONL row).
13
+ * - Assistant chunks (`task:text`) and tool events (`task:tool`) are
14
+ * accumulated in an in-memory `InFlightTurnAggregator`; nothing is
15
+ * written until `task:end`, at which point a single
16
+ * `assistant_turn` JSONL row captures the entire turn (full text,
17
+ * every tool call in invocation order, stop reason, optional
18
+ * terminal error). This matches OpenClaw's "one line = one
19
+ * complete message" model and avoids the fragmented transcript
20
+ * produced by the legacy "one row per chunk" implementation.
21
+ *
22
+ * Single-session mode (`fixedSessionId`):
23
+ *
24
+ * When a fixedSessionId is supplied, every ACP session-management
25
+ * method (`new`/`load`/`list`/`rename`/`delete`) collapses onto that
26
+ * one sid. The mobile/web client receives `multipleSessions: false`
27
+ * in the initialize handshake so the UI hides session-switching
28
+ * affordances. This is the production configuration for device-agent
29
+ * — a single physical device serialises tasks anyway, and a fixed
30
+ * sid keeps the JSONL transcript stable across reconnects.
31
+ *
32
+ * Replay model — push-style:
33
+ *
34
+ * `session/load` does NOT return history in the RPC `result` (the
35
+ * web/mobile SDKs ignore that field). Instead, it pushes one
36
+ * `history_turn` envelope per persisted turn followed by an
37
+ * in-flight snapshot (streaming-style, WITHOUT `stream_complete`)
38
+ * when a task is mid-flight. This matches beeos-claw's
39
+ * `handleSessionLoad` exactly so the same chat-store reducer paths
40
+ * handle both agents.
16
41
  */
17
42
  import { randomUUID } from "node:crypto";
18
43
  import { newSession } from "../core/session.js";
19
- import { MessageStore } from "./message-store.js";
44
+ import { MessageStore, } from "./message-store.js";
45
+ import { InFlightTurnAggregator, } from "./in-flight-turn.js";
20
46
  export class SessionManager {
21
47
  host;
22
48
  store;
23
49
  notifier;
24
50
  onSetModel;
51
+ fixedSessionId;
25
52
  maxSessions;
26
53
  sessionTtlMs;
27
54
  sessions = new Map();
28
55
  /** taskId → sessionId for routing host events to the right ACP session. */
29
56
  taskToSession = new Map();
57
+ /** Per-task in-flight aggregators. Keyed by taskId. */
58
+ inFlightByTask = new Map();
30
59
  activeSessionId = null;
31
60
  currentTask = null;
32
61
  unsubscribe = null;
@@ -35,9 +64,17 @@ export class SessionManager {
35
64
  this.store = opts.messageStore ?? new MessageStore();
36
65
  this.notifier = opts.notifier;
37
66
  this.onSetModel = opts.onSetModel;
67
+ if (opts.fixedSessionId)
68
+ this.fixedSessionId = opts.fixedSessionId;
38
69
  this.maxSessions = opts.maxSessions ?? 50;
39
70
  this.sessionTtlMs = opts.sessionTtlMs ?? 60 * 60 * 1000;
40
71
  this.unsubscribe = this.host.on((ev) => this.handleHostEvent(ev));
72
+ // In single-session mode, eagerly materialise the fixed session so
73
+ // listSessions / load behave deterministically even before the
74
+ // first prompt.
75
+ if (this.fixedSessionId) {
76
+ this.ensureFixedSession();
77
+ }
41
78
  }
42
79
  /** Detach from the host bus (used when shutting down the ACP plugin). */
43
80
  detach() {
@@ -48,6 +85,10 @@ export class SessionManager {
48
85
  }
49
86
  // ── ACP semantics — name mirrors Python SessionHandler ───────────────────
50
87
  newSession() {
88
+ if (this.fixedSessionId) {
89
+ this.ensureFixedSession();
90
+ return { sessionId: this.fixedSessionId };
91
+ }
51
92
  this.evictExpired();
52
93
  const id = randomUUID();
53
94
  this.sessions.set(id, newSession(id));
@@ -55,34 +96,70 @@ export class SessionManager {
55
96
  this.store.upsertSession(id);
56
97
  return { sessionId: id };
57
98
  }
99
+ /**
100
+ * Load a session.
101
+ *
102
+ * Push-replay model: emit one `history_turn` envelope per persisted
103
+ * turn so the chat-store sidecar resolver can reconstruct the bubble
104
+ * graph identically to a fresh streaming run. If a task is currently
105
+ * in-flight on this sid, additionally emit a streaming-style
106
+ * snapshot (`agent_message_chunk` + `tool_call` + `tool_call_update`)
107
+ * WITHOUT the closing `stream_complete` so the client knows the turn
108
+ * is still active.
109
+ *
110
+ * Returns `{ sessionId }` only — the legacy `history` field has been
111
+ * dropped because no client reads it (see explore audit).
112
+ */
58
113
  loadSession(sessionId) {
59
- let id = sessionId;
60
- const known = this.sessions.has(id) || this.store.sessionExists(id);
61
- if (!known)
62
- id = randomUUID();
114
+ const id = this.aliasSessionId(sessionId);
63
115
  if (!this.sessions.has(id))
64
116
  this.sessions.set(id, newSession(id));
65
117
  const s = this.sessions.get(id);
66
118
  s.lastActive = Date.now();
67
119
  this.activeSessionId = id;
68
120
  this.store.upsertSession(id);
69
- return { sessionId: id, history: this.store.getMessagesAsDicts(id, 50) };
121
+ // (1) Replay every persisted turn.
122
+ for (const rec of this.store.getTurns(id)) {
123
+ this.emitHistoryTurn(id, rec);
124
+ }
125
+ // (2) Emit in-flight snapshot if a task is still running on this sid.
126
+ const inflight = this.findInflightForSession(id);
127
+ if (inflight)
128
+ this.emitInflightSnapshot(id, inflight);
129
+ return { sessionId: id };
70
130
  }
71
131
  listSessions() {
132
+ if (this.fixedSessionId) {
133
+ this.ensureFixedSession();
134
+ return { sessions: this.store.listSessions(50) };
135
+ }
72
136
  this.evictExpired();
73
137
  return { sessions: this.store.listSessions(50) };
74
138
  }
75
139
  renameSession(sessionId, title) {
76
- const s = this.sessions.get(sessionId);
140
+ const id = this.aliasSessionId(sessionId);
141
+ const s = this.sessions.get(id);
77
142
  if (s) {
78
143
  s.title = title || s.title;
79
144
  s.lastActive = Date.now();
80
145
  }
81
146
  if (title)
82
- this.store.renameSession(sessionId, title);
83
- return { sessionId, title };
147
+ this.store.renameSession(id, title);
148
+ return { sessionId: id, title };
84
149
  }
150
+ /**
151
+ * Delete a session.
152
+ *
153
+ * In single-session mode, this collapses to `clearSession` because
154
+ * the one-and-only session can't be removed without losing the
155
+ * device's identity. Otherwise drops the in-memory + persisted state
156
+ * for `sessionId`.
157
+ */
85
158
  deleteSession(sessionId) {
159
+ if (this.fixedSessionId) {
160
+ this.clearSession(this.fixedSessionId);
161
+ return { deleted: false };
162
+ }
86
163
  const had = this.sessions.has(sessionId);
87
164
  this.sessions.delete(sessionId);
88
165
  if (this.activeSessionId === sessionId)
@@ -91,23 +168,30 @@ export class SessionManager {
91
168
  return { deleted: had };
92
169
  }
93
170
  clearSession(sessionId) {
94
- const s = this.sessions.get(sessionId);
171
+ const id = this.aliasSessionId(sessionId);
172
+ const s = this.sessions.get(id);
95
173
  if (s) {
96
174
  s.history = [];
97
175
  s.lastActive = Date.now();
98
176
  }
99
- this.store.clearSession(sessionId);
177
+ this.store.clearSession(id);
178
+ // Also drop any pending in-flight aggregators that were targeting
179
+ // this sid — the user explicitly said they want a blank slate.
180
+ for (const [taskId, sid] of this.taskToSession) {
181
+ if (sid === id)
182
+ this.inFlightByTask.delete(taskId);
183
+ }
100
184
  return { cleared: !!s };
101
185
  }
102
186
  setMode(sessionId, mode) {
103
- return { sessionId, mode };
187
+ return { sessionId: this.aliasSessionId(sessionId), mode };
104
188
  }
105
189
  async setModel(sessionId, model, provider = "") {
106
190
  if (!model)
107
191
  throw new Error("model is required");
108
192
  if (this.onSetModel)
109
193
  await this.onSetModel(provider, model);
110
- return { sessionId, provider, model };
194
+ return { sessionId: this.aliasSessionId(sessionId), provider, model };
111
195
  }
112
196
  /**
113
197
  * Run a prompt to completion and return the ACP `stopReason`.
@@ -122,13 +206,19 @@ export class SessionManager {
122
206
  const session = this.resolveSession(sessionIdHint);
123
207
  session.lastActive = Date.now();
124
208
  session.history.push({ role: "user", content: prompt, createdAt: session.lastActive });
125
- this.store.addMessage(session.id, "user", prompt, "text");
126
209
  this.activeSessionId = session.id;
127
210
  this.notify(session.id, { sessionUpdate: "agent_status", status: "running" });
128
211
  const submitted = this.host.submitTask(prompt, { correlationTag: session.id });
129
212
  this.taskToSession.set(submitted.taskId, session.id);
130
213
  this.currentTask = submitted;
131
214
  session.currentTaskId = submitted.taskId;
215
+ // Persist the user message AFTER taskToSession is wired so a
216
+ // concurrent loadSession from another connection can see this row
217
+ // alongside the upcoming in-flight assistant snapshot.
218
+ // turnId mirrors agent-host's convention (turnId === taskId).
219
+ this.store.appendUserMessage(session.id, prompt, {
220
+ turnId: submitted.taskId,
221
+ });
132
222
  let result;
133
223
  try {
134
224
  result = await submitted.done;
@@ -137,6 +227,10 @@ export class SessionManager {
137
227
  this.currentTask = null;
138
228
  session.currentTaskId = null;
139
229
  this.taskToSession.delete(submitted.taskId);
230
+ // Belt-and-suspenders: in normal flow `task:end` already
231
+ // finalised + cleared the in-flight; defend against the agent
232
+ // crashing without firing task:end.
233
+ this.inFlightByTask.delete(submitted.taskId);
140
234
  this.notify(session.id, { sessionUpdate: "agent_status", status: "idle" });
141
235
  }
142
236
  return { stopReason: result.cancelled ? "cancelled" : "end_turn" };
@@ -149,58 +243,50 @@ export class SessionManager {
149
243
  }
150
244
  // ── Internal ──────────────────────────────────────────────────────────────
151
245
  /**
152
- * Translate host `AgentEvent`s into ACP `session/update` envelopes.
153
- *
154
- * Handles five legitimate ACP-emitting sources plus structural events:
155
- * - `task:text` — streamed user-visible reply (agent_message_chunk)
156
- * - `task:reasoning` — streaming "thinking" delta (agent_thought_chunk):
157
- * planner plan/replan descriptions and per-step
158
- * VLM rationale.
159
- * - `task:tool` — start / update / end (tool_call / tool_call_update).
160
- * `update` carries partial progress for long-running
161
- * tools (large APK install, long shell streams,
162
- * large screenshots) so the chat-store tool card
163
- * can refresh in-place without waiting for `end`.
164
- * Mirrors beeos-claw's tool_call_update in_progress
165
- * semantics (see acp-gateway/tool-call-payload-builder.ts).
166
- * - `task:notice` — out-of-band protocol notice. level=warn
167
- * (transient retry hints) is dropped — noise
168
- * the user can't act on. level=error (terminal
169
- * failure) becomes a `system_message_chunk`
170
- * with `metadata.kind = "agent_notice"`; the
171
- * web client merges it into the matching
172
- * assistant bubble's footer. NOT persisted.
173
- * - `task:step` — structural step record (no ACP emission, kept
174
- * for HTTP debug / future plugins).
175
- * - `task:end` — terminal lifecycle event. We emit the canonical
176
- * ACP `stream_complete` envelope here (BEFORE the
177
- * `prompt()` finally block fires `agent_status:
178
- * "idle"`). chat-store's `applyIncrementalUpdate`
179
- * relies on `stream_complete` to (a) collapse
180
- * streaming text part `state` from "streaming"
181
- * back to "done" so the cursor caret stops
182
- * rendering, and (b) trim `pendingMap` /
183
- * `currentAsstId` so history-replay paths don't
184
- * re-flag the just-finished turn as processing.
185
- * Without it the bubble caret never collapses
186
- * and reload paths see a stale `processing`
187
- * pending — see plan
188
- * `device-agent_stream_complete_对齐`.
189
- *
190
- * Every emitted envelope carries `_turnId` (groups all chunks of a
191
- * single user prompt) and `messageId` (the per-task authoritative id
192
- * shared by every chunk / thought / tool / notice in this turn). The
193
- * web `chat-store` sidecar resolver pins thoughts and tool calls to
194
- * the assistant bubble bearing the matching `_messageId`.
246
+ * Translate host `AgentEvent`s into ACP `session/update` envelopes
247
+ * AND accumulate the assistant turn into the in-memory aggregator
248
+ * for this task. The aggregator is finalised on `task:end` and
249
+ * persisted as a single `assistant_turn` JSONL row — chunk-level
250
+ * persistence is intentionally NOT done (it produces fragmented
251
+ * transcripts that can't be reconstructed cleanly on reload).
195
252
  *
196
- * Events for tasks not owned by this SessionManager are ignored —
197
- * other plugins (HTTP debug, custom) may submit tasks through the
198
- * same host bus.
253
+ * Source events:
254
+ * - `task:start` — allocate the in-flight aggregator.
255
+ * - `task:text` — append delta to in-flight + emit
256
+ * `agent_message_chunk`.
257
+ * - `task:reasoning` — emit `agent_thought_chunk` only (NOT
258
+ * persisted — bloats LLM context on resume).
259
+ * - `task:tool` — track in in-flight + emit
260
+ * `tool_call` / `tool_call_update`. Mid-flight
261
+ * progress is forwarded as `in_progress`.
262
+ * - `task:notice` — `warn` is dropped entirely; `error` becomes
263
+ * a `system_message_chunk` with
264
+ * `metadata.kind = "agent_notice"` AND is
265
+ * captured as the in-flight `error` field so
266
+ * the persisted turn carries the failure
267
+ * text.
268
+ * - `task:step` — structural; no ACP emission.
269
+ * - `task:end` — finalise the in-flight, persist a single
270
+ * `assistant_turn` row, emit `stream_complete`
271
+ * BEFORE the closing `agent_status: idle`
272
+ * (chat-store relies on this order).
199
273
  */
200
274
  handleHostEvent(ev) {
201
275
  const sessionId = this.taskToSession.get(ev.taskId);
202
276
  if (!sessionId)
203
277
  return;
278
+ if (ev.type === "task:start") {
279
+ const inflight = InFlightTurnAggregator.start({
280
+ taskId: ev.taskId,
281
+ turnId: ev.turnId,
282
+ // task:start doesn't carry messageId — we'll populate it on
283
+ // the first text/tool/reasoning event below.
284
+ messageId: ev.taskId,
285
+ now: ev.createdAt,
286
+ });
287
+ this.inFlightByTask.set(ev.taskId, inflight);
288
+ return;
289
+ }
204
290
  if (ev.type === "task:text") {
205
291
  if (!ev.text)
206
292
  return;
@@ -210,7 +296,8 @@ export class SessionManager {
210
296
  _turnId: ev.turnId,
211
297
  messageId: ev.messageId,
212
298
  });
213
- this.store.addMessage(sessionId, "assistant", ev.text, "text");
299
+ const inflight = this.ensureInflight(ev.taskId, ev.turnId, ev.messageId);
300
+ inflight.appendText(ev.text);
214
301
  return;
215
302
  }
216
303
  if (ev.type === "task:reasoning") {
@@ -218,9 +305,8 @@ export class SessionManager {
218
305
  // VLM rationale) flow through the dedicated `agent_thought_chunk`
219
306
  // ACP channel so the web UI can render them in the reasoning side
220
307
  // panel without polluting the main conversation. We intentionally
221
- // do NOT persist reasoning text into MessageStore — it would bloat
222
- // the LLM context window on resume and is purely a presentation
223
- // signal.
308
+ // do NOT persist reasoning text — it would bloat the LLM context
309
+ // window on resume and is purely a presentation signal.
224
310
  if (!ev.text)
225
311
  return;
226
312
  this.notify(sessionId, {
@@ -233,6 +319,7 @@ export class SessionManager {
233
319
  }
234
320
  if (ev.type === "task:tool") {
235
321
  const t = ev.tool;
322
+ const inflight = this.ensureInflight(ev.taskId, ev.turnId, ev.messageId);
236
323
  if (t.status === "start") {
237
324
  this.notify(sessionId, {
238
325
  sessionUpdate: "tool_call",
@@ -243,21 +330,9 @@ export class SessionManager {
243
330
  _turnId: ev.turnId,
244
331
  messageId: ev.messageId,
245
332
  });
246
- this.store.addMessage(sessionId, "assistant", t.name, "tool_call", {
247
- toolCallId: t.id,
248
- parameters: t.args,
249
- });
333
+ inflight.noteToolStart({ id: t.id, name: t.name, args: t.args });
250
334
  }
251
335
  else if (t.status === "update") {
252
- // Mid-flight progress for a long-running tool (large APK install,
253
- // streaming shell, large screenshot). Forwarded as
254
- // `tool_call_update` with `status: "in_progress"` so chat-store
255
- // can refresh the tool card output in-place without waiting for
256
- // the terminal `end` event. Mirrors beeos-claw's in_progress
257
- // semantics (acp-gateway/tool-call-payload-builder.ts L138-148).
258
- // NOT persisted — only the terminal `end` writes a `tool_result`
259
- // row to MessageStore so resume context isn't bloated by every
260
- // intermediate progress frame.
261
336
  const output = t.result === undefined
262
337
  ? undefined
263
338
  : typeof t.result === "string"
@@ -273,6 +348,7 @@ export class SessionManager {
273
348
  if (output !== undefined)
274
349
  update.output = output;
275
350
  this.notify(sessionId, update);
351
+ inflight.noteToolUpdate({ id: t.id, ...(output !== undefined ? { output } : {}) });
276
352
  }
277
353
  else if (t.status === "end") {
278
354
  const status = t.isError ? "failed" : "completed";
@@ -285,7 +361,11 @@ export class SessionManager {
285
361
  _turnId: ev.turnId,
286
362
  messageId: ev.messageId,
287
363
  });
288
- this.store.addMessage(sessionId, "assistant", output || `${t.name} ${status}`, "tool_result", { toolCallId: t.id, status });
364
+ inflight.noteToolEnd({
365
+ id: t.id,
366
+ output: output || `${t.name} ${status}`,
367
+ isError: !!t.isError,
368
+ });
289
369
  }
290
370
  return;
291
371
  }
@@ -304,17 +384,7 @@ export class SessionManager {
304
384
  // this kind and merges the notice into the matching
305
385
  // assistant bubble's `metadata.notices` (correlated by
306
386
  // `_turnId`), so the user sees a small inline footer line
307
- // instead of a centered gray capsule. Older clients that
308
- // don't know `agent_notice` still render it as a plain
309
- // system message — the kind tag is purely additive.
310
- //
311
- // Intentionally NOT written to MessageStore — replaying retry
312
- // hints / terminal-failure capsules on session resume would
313
- // bloat the LLM context with stale degraded-state signals. We
314
- // forward `_turnId` for ordering / merge correlation but DROP
315
- // `messageId` because chat-store's sidecar resolver would
316
- // otherwise still try to merge a system row with assistant
317
- // rows sharing that id.
387
+ // instead of a centered gray capsule.
318
388
  if (ev.level === "warn")
319
389
  return;
320
390
  if (!ev.text)
@@ -325,38 +395,35 @@ export class SessionManager {
325
395
  _turnId: ev.turnId,
326
396
  metadata: { kind: "agent_notice", level: ev.level },
327
397
  });
398
+ // Capture the terminal text on the in-flight so the persisted
399
+ // assistant_turn record carries an `error` field even when the
400
+ // model produced no final reply.
401
+ const inflight = this.inFlightByTask.get(ev.taskId);
402
+ if (inflight)
403
+ inflight.noteFatalNotice(ev.text);
328
404
  return;
329
405
  }
330
406
  if (ev.type === "task:step") {
331
- // `task:step` is a structural event (`{ stepIndex, action, finished,
332
- // message }`) consumed by HTTP debug / future plugins. Per-step
333
- // rationale is now delivered via `task:reasoning` →
334
- // `agent_thought_chunk` (see above), so we no longer mis-emit
335
- // `step.thinking` as `agent_message_chunk` here — that double-fire
336
- // was leaking VLM rationale into the user-visible reply.
407
+ // Structural event — consumed by HTTP debug. No ACP emission.
337
408
  return;
338
409
  }
339
410
  if (ev.type === "task:end") {
340
- const status = ev.error ? "failed" : ev.result.cancelled ? "cancelled" : "completed";
341
- // Emit ACP `stream_complete` BEFORE the `prompt()` finally block
342
- // fires `agent_status: "idle"`. Order matters: chat-store treats
343
- // `stream_complete` as the canonical "this turn is over" signal
344
- // (collapses text part `state: "streaming"` → "done", trims
345
- // `pendingMap` / `currentAsstId`). Sending it from the host event
346
- // path guarantees it arrives before idle because `task:end` is
347
- // dispatched synchronously from `agent-host.runOne` BEFORE the
348
- // task's `done` Promise resolves — and the `prompt()` finally is
349
- // only entered after `await submitted.done`.
350
- //
351
- // `stopReason` mirrors beeos-claw's three-valued enum (`end_turn`
352
- // / `cancelled` / `error`). The `error` field is forwarded only on
353
- // the failure path so chat-store can surface it on the bubble
354
- // footer; success / cancel envelopes omit it entirely.
355
411
  const stopReason = ev.error
356
412
  ? "error"
357
413
  : ev.result.cancelled
358
414
  ? "cancelled"
359
415
  : "end_turn";
416
+ // Finalise + persist BEFORE emitting `stream_complete`. This
417
+ // ordering matters for any concurrent `session/load` that comes
418
+ // in between `stream_complete` and the prompt's idle signal —
419
+ // the JSONL row should be on disk by the time we tell the
420
+ // client the turn is done.
421
+ const inflight = this.inFlightByTask.get(ev.taskId);
422
+ if (inflight) {
423
+ const record = inflight.finalize(stopReason, ev.completedAt);
424
+ this.store.appendAssistantTurn(sessionId, record);
425
+ this.inFlightByTask.delete(ev.taskId);
426
+ }
360
427
  const completeUpdate = {
361
428
  sessionUpdate: "stream_complete",
362
429
  stopReason,
@@ -365,13 +432,13 @@ export class SessionManager {
365
432
  if (ev.error)
366
433
  completeUpdate.error = ev.error;
367
434
  this.notify(sessionId, completeUpdate);
368
- this.store.addMessage(sessionId, "system", `Task ${status}`, "status", {
369
- taskId: ev.taskId,
370
- status,
371
- });
372
435
  }
373
436
  }
374
437
  resolveSession(idHint) {
438
+ if (this.fixedSessionId) {
439
+ this.ensureFixedSession();
440
+ return this.sessions.get(this.fixedSessionId);
441
+ }
375
442
  if (idHint && this.sessions.has(idHint))
376
443
  return this.sessions.get(idHint);
377
444
  if (idHint && this.store.sessionExists(idHint)) {
@@ -384,12 +451,143 @@ export class SessionManager {
384
451
  const { sessionId } = this.newSession();
385
452
  return this.sessions.get(sessionId);
386
453
  }
454
+ /** Map any sid the client passes onto the canonical sid in fixed mode. */
455
+ aliasSessionId(sid) {
456
+ return this.fixedSessionId ?? sid;
457
+ }
458
+ /** Materialise the fixed session in-memory + on disk if it isn't yet. */
459
+ ensureFixedSession() {
460
+ if (!this.fixedSessionId)
461
+ return;
462
+ if (!this.sessions.has(this.fixedSessionId)) {
463
+ this.sessions.set(this.fixedSessionId, newSession(this.fixedSessionId));
464
+ }
465
+ if (!this.store.sessionExists(this.fixedSessionId)) {
466
+ this.store.upsertSession(this.fixedSessionId);
467
+ }
468
+ this.activeSessionId = this.fixedSessionId;
469
+ }
470
+ /**
471
+ * Lazily create an in-flight aggregator for events that arrive
472
+ * without a preceding `task:start` (defensive — agent-host always
473
+ * fires start, but tests + alternate adapters may not).
474
+ */
475
+ ensureInflight(taskId, turnId, messageId) {
476
+ let inflight = this.inFlightByTask.get(taskId);
477
+ if (!inflight) {
478
+ inflight = InFlightTurnAggregator.start({ taskId, turnId, messageId });
479
+ this.inFlightByTask.set(taskId, inflight);
480
+ }
481
+ return inflight;
482
+ }
483
+ findInflightForSession(sessionId) {
484
+ for (const [taskId, sid] of this.taskToSession) {
485
+ if (sid !== sessionId)
486
+ continue;
487
+ const inflight = this.inFlightByTask.get(taskId);
488
+ if (inflight)
489
+ return inflight;
490
+ }
491
+ return null;
492
+ }
493
+ /**
494
+ * Emit a single persisted record as a `history_turn` envelope.
495
+ *
496
+ * Mirrors beeos-claw `bridge-core.replayAssistantTurnAsStream` —
497
+ * `history_turn` is a stamped-done snapshot of an entire turn that
498
+ * the chat-store reducer renders as a complete bubble (no streaming
499
+ * cursor). For assistant turns, we serialise tool calls into the
500
+ * shape `apply-update.buildToolCalls` accepts.
501
+ */
502
+ emitHistoryTurn(sessionId, rec) {
503
+ if (rec.type === "user_message") {
504
+ const update = {
505
+ sessionUpdate: "history_turn",
506
+ role: "user",
507
+ content: rec.content,
508
+ timestamp: new Date(rec.ts).toISOString(),
509
+ };
510
+ if (rec.messageId)
511
+ update.messageId = rec.messageId;
512
+ if (rec.turnId)
513
+ update._turnId = rec.turnId;
514
+ this.notify(sessionId, update);
515
+ return;
516
+ }
517
+ // assistant_turn
518
+ const turn = rec;
519
+ const toolCalls = turn.toolCalls.map((tc) => ({
520
+ id: tc.id,
521
+ name: tc.name,
522
+ arguments: typeof tc.args === "string" ? tc.args : JSON.stringify(tc.args ?? {}),
523
+ status: tc.status,
524
+ ...(tc.output !== undefined ? { output: tc.output } : {}),
525
+ isError: tc.status === "failed",
526
+ }));
527
+ const update = {
528
+ sessionUpdate: "history_turn",
529
+ role: "assistant",
530
+ content: turn.text,
531
+ timestamp: new Date(turn.ts).toISOString(),
532
+ messageId: turn.messageId,
533
+ _turnId: turn.turnId,
534
+ };
535
+ if (toolCalls.length > 0)
536
+ update.toolCalls = toolCalls;
537
+ if (turn.error)
538
+ update.metadata = { error: turn.error };
539
+ this.notify(sessionId, update);
540
+ }
541
+ /**
542
+ * Emit the current state of an in-flight turn as streaming-style
543
+ * envelopes WITHOUT the closing `stream_complete`. The client uses
544
+ * the absence of `stream_complete` to recognise the turn is still
545
+ * generating, matching beeos-claw's `emitCurrentStreamSnapshot`.
546
+ */
547
+ emitInflightSnapshot(sessionId, inflight) {
548
+ const text = inflight.currentText();
549
+ if (text) {
550
+ this.notify(sessionId, {
551
+ sessionUpdate: "agent_message_chunk",
552
+ content: { type: "text", text },
553
+ _turnId: inflight.turnId,
554
+ messageId: inflight.messageId,
555
+ });
556
+ }
557
+ for (const tc of inflight.currentToolCalls()) {
558
+ this.notify(sessionId, {
559
+ sessionUpdate: "tool_call",
560
+ toolCallId: tc.id,
561
+ title: tc.name,
562
+ arguments: JSON.stringify(tc.args),
563
+ status: "in_progress",
564
+ _turnId: inflight.turnId,
565
+ messageId: inflight.messageId,
566
+ });
567
+ if (tc.status !== "in_progress") {
568
+ const update = {
569
+ sessionUpdate: "tool_call_update",
570
+ toolCallId: tc.id,
571
+ status: tc.status,
572
+ _turnId: inflight.turnId,
573
+ messageId: inflight.messageId,
574
+ };
575
+ if (tc.output !== undefined)
576
+ update.output = tc.output;
577
+ this.notify(sessionId, update);
578
+ }
579
+ }
580
+ // Deliberately do NOT emit `stream_complete` — its absence is the
581
+ // signal the client uses to recognise a still-generating turn.
582
+ }
387
583
  notify(sessionId, update) {
388
584
  if (!this.notifier)
389
585
  return;
390
586
  this.notifier.send(sessionId, update);
391
587
  }
392
588
  evictExpired() {
589
+ if (this.fixedSessionId)
590
+ return;
393
591
  const now = Date.now();
394
592
  for (const [id, s] of this.sessions) {
395
593
  if (now - s.lastActive > this.sessionTtlMs) {
@@ -417,9 +615,7 @@ export class SessionManager {
417
615
  * `resource_link` URIs are preserved verbatim on their own line at the
418
616
  * end of the merged prompt, so the Resource Resolution Layer's parsers
419
617
  * (`UrlParser`, `BeeosFileParser`) can pick them up without any extra
420
- * out-of-band channel. This is the single ACP integration point — every
421
- * "the agent has access to a user-supplied resource" path goes through
422
- * here.
618
+ * out-of-band channel.
423
619
  */
424
620
  function normalizePrompt(raw) {
425
621
  if (typeof raw === "string")