@beeos-ai/device-agent 0.5.1 → 0.7.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 (51) hide show
  1. package/.pi/extensions/android-tools.ts +24 -3
  2. package/dist/adapters/agent-gateway-client.d.ts +25 -0
  3. package/dist/adapters/agent-gateway-client.js +49 -0
  4. package/dist/adapters/agent-gateway-client.js.map +1 -1
  5. package/dist/adapters/user-file-uploader.d.ts +74 -0
  6. package/dist/adapters/user-file-uploader.js +78 -0
  7. package/dist/adapters/user-file-uploader.js.map +1 -0
  8. package/dist/app/in-flight-turn.d.ts +120 -0
  9. package/dist/app/in-flight-turn.js +158 -0
  10. package/dist/app/in-flight-turn.js.map +1 -0
  11. package/dist/app/jsonl-store.d.ts +109 -0
  12. package/dist/app/jsonl-store.js +402 -0
  13. package/dist/app/jsonl-store.js.map +1 -0
  14. package/dist/app/message-store.d.ts +82 -31
  15. package/dist/app/message-store.js +109 -48
  16. package/dist/app/message-store.js.map +1 -1
  17. package/dist/app/plugin-commands/registry.d.ts +41 -0
  18. package/dist/app/plugin-commands/registry.js +62 -0
  19. package/dist/app/plugin-commands/registry.js.map +1 -0
  20. package/dist/app/plugin-commands/screenshot-command.d.ts +32 -0
  21. package/dist/app/plugin-commands/screenshot-command.js +44 -0
  22. package/dist/app/plugin-commands/screenshot-command.js.map +1 -0
  23. package/dist/app/plugin-commands/types.d.ts +63 -0
  24. package/dist/app/plugin-commands/types.js +26 -0
  25. package/dist/app/plugin-commands/types.js.map +1 -0
  26. package/dist/app/screenshot-service.d.ts +68 -0
  27. package/dist/app/screenshot-service.js +64 -0
  28. package/dist/app/screenshot-service.js.map +1 -0
  29. package/dist/app/session-manager.d.ts +150 -55
  30. package/dist/app/session-manager.js +385 -122
  31. package/dist/app/session-manager.js.map +1 -1
  32. package/dist/bootstrap.js +5 -1
  33. package/dist/bootstrap.js.map +1 -1
  34. package/dist/config/paths.d.ts +48 -0
  35. package/dist/config/paths.js +59 -0
  36. package/dist/config/paths.js.map +1 -0
  37. package/dist/inbound/acp/handlers/device.d.ts +9 -1
  38. package/dist/inbound/acp/handlers/device.js +9 -1
  39. package/dist/inbound/acp/handlers/device.js.map +1 -1
  40. package/dist/inbound/acp/handlers/initialize.d.ts +17 -5
  41. package/dist/inbound/acp/handlers/initialize.js +19 -7
  42. package/dist/inbound/acp/handlers/initialize.js.map +1 -1
  43. package/dist/inbound/acp/handlers/session.d.ts +0 -1
  44. package/dist/inbound/acp/handlers/skills.d.ts +24 -2
  45. package/dist/inbound/acp/handlers/skills.js +31 -2
  46. package/dist/inbound/acp/handlers/skills.js.map +1 -1
  47. package/dist/orchestrator-system-prompt.js +2 -2
  48. package/dist/plugins/acp/index.d.ts +26 -1
  49. package/dist/plugins/acp/index.js +86 -3
  50. package/dist/plugins/acp/index.js.map +1 -1
  51. package/package.json +1 -1
@@ -6,27 +6,57 @@
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;
54
+ pluginCommands;
27
55
  sessions = new Map();
28
56
  /** taskId → sessionId for routing host events to the right ACP session. */
29
57
  taskToSession = new Map();
58
+ /** Per-task in-flight aggregators. Keyed by taskId. */
59
+ inFlightByTask = new Map();
30
60
  activeSessionId = null;
31
61
  currentTask = null;
32
62
  unsubscribe = null;
@@ -35,9 +65,18 @@ export class SessionManager {
35
65
  this.store = opts.messageStore ?? new MessageStore();
36
66
  this.notifier = opts.notifier;
37
67
  this.onSetModel = opts.onSetModel;
68
+ if (opts.fixedSessionId)
69
+ this.fixedSessionId = opts.fixedSessionId;
38
70
  this.maxSessions = opts.maxSessions ?? 50;
39
71
  this.sessionTtlMs = opts.sessionTtlMs ?? 60 * 60 * 1000;
72
+ this.pluginCommands = opts.pluginCommands;
40
73
  this.unsubscribe = this.host.on((ev) => this.handleHostEvent(ev));
74
+ // In single-session mode, eagerly materialise the fixed session so
75
+ // listSessions / load behave deterministically even before the
76
+ // first prompt.
77
+ if (this.fixedSessionId) {
78
+ this.ensureFixedSession();
79
+ }
41
80
  }
42
81
  /** Detach from the host bus (used when shutting down the ACP plugin). */
43
82
  detach() {
@@ -48,6 +87,10 @@ export class SessionManager {
48
87
  }
49
88
  // ── ACP semantics — name mirrors Python SessionHandler ───────────────────
50
89
  newSession() {
90
+ if (this.fixedSessionId) {
91
+ this.ensureFixedSession();
92
+ return { sessionId: this.fixedSessionId };
93
+ }
51
94
  this.evictExpired();
52
95
  const id = randomUUID();
53
96
  this.sessions.set(id, newSession(id));
@@ -55,34 +98,70 @@ export class SessionManager {
55
98
  this.store.upsertSession(id);
56
99
  return { sessionId: id };
57
100
  }
101
+ /**
102
+ * Load a session.
103
+ *
104
+ * Push-replay model: emit one `history_turn` envelope per persisted
105
+ * turn so the chat-store sidecar resolver can reconstruct the bubble
106
+ * graph identically to a fresh streaming run. If a task is currently
107
+ * in-flight on this sid, additionally emit a streaming-style
108
+ * snapshot (`agent_message_chunk` + `tool_call` + `tool_call_update`)
109
+ * WITHOUT the closing `stream_complete` so the client knows the turn
110
+ * is still active.
111
+ *
112
+ * Returns `{ sessionId }` only — the legacy `history` field has been
113
+ * dropped because no client reads it (see explore audit).
114
+ */
58
115
  loadSession(sessionId) {
59
- let id = sessionId;
60
- const known = this.sessions.has(id) || this.store.sessionExists(id);
61
- if (!known)
62
- id = randomUUID();
116
+ const id = this.aliasSessionId(sessionId);
63
117
  if (!this.sessions.has(id))
64
118
  this.sessions.set(id, newSession(id));
65
119
  const s = this.sessions.get(id);
66
120
  s.lastActive = Date.now();
67
121
  this.activeSessionId = id;
68
122
  this.store.upsertSession(id);
69
- return { sessionId: id, history: this.store.getMessagesAsDicts(id, 50) };
123
+ // (1) Replay every persisted turn.
124
+ for (const rec of this.store.getTurns(id)) {
125
+ this.emitHistoryTurn(id, rec);
126
+ }
127
+ // (2) Emit in-flight snapshot if a task is still running on this sid.
128
+ const inflight = this.findInflightForSession(id);
129
+ if (inflight)
130
+ this.emitInflightSnapshot(id, inflight);
131
+ return { sessionId: id };
70
132
  }
71
133
  listSessions() {
134
+ if (this.fixedSessionId) {
135
+ this.ensureFixedSession();
136
+ return { sessions: this.store.listSessions(50) };
137
+ }
72
138
  this.evictExpired();
73
139
  return { sessions: this.store.listSessions(50) };
74
140
  }
75
141
  renameSession(sessionId, title) {
76
- const s = this.sessions.get(sessionId);
142
+ const id = this.aliasSessionId(sessionId);
143
+ const s = this.sessions.get(id);
77
144
  if (s) {
78
145
  s.title = title || s.title;
79
146
  s.lastActive = Date.now();
80
147
  }
81
148
  if (title)
82
- this.store.renameSession(sessionId, title);
83
- return { sessionId, title };
149
+ this.store.renameSession(id, title);
150
+ return { sessionId: id, title };
84
151
  }
152
+ /**
153
+ * Delete a session.
154
+ *
155
+ * In single-session mode, this collapses to `clearSession` because
156
+ * the one-and-only session can't be removed without losing the
157
+ * device's identity. Otherwise drops the in-memory + persisted state
158
+ * for `sessionId`.
159
+ */
85
160
  deleteSession(sessionId) {
161
+ if (this.fixedSessionId) {
162
+ this.clearSession(this.fixedSessionId);
163
+ return { deleted: false };
164
+ }
86
165
  const had = this.sessions.has(sessionId);
87
166
  this.sessions.delete(sessionId);
88
167
  if (this.activeSessionId === sessionId)
@@ -91,23 +170,30 @@ export class SessionManager {
91
170
  return { deleted: had };
92
171
  }
93
172
  clearSession(sessionId) {
94
- const s = this.sessions.get(sessionId);
173
+ const id = this.aliasSessionId(sessionId);
174
+ const s = this.sessions.get(id);
95
175
  if (s) {
96
176
  s.history = [];
97
177
  s.lastActive = Date.now();
98
178
  }
99
- this.store.clearSession(sessionId);
179
+ this.store.clearSession(id);
180
+ // Also drop any pending in-flight aggregators that were targeting
181
+ // this sid — the user explicitly said they want a blank slate.
182
+ for (const [taskId, sid] of this.taskToSession) {
183
+ if (sid === id)
184
+ this.inFlightByTask.delete(taskId);
185
+ }
100
186
  return { cleared: !!s };
101
187
  }
102
188
  setMode(sessionId, mode) {
103
- return { sessionId, mode };
189
+ return { sessionId: this.aliasSessionId(sessionId), mode };
104
190
  }
105
191
  async setModel(sessionId, model, provider = "") {
106
192
  if (!model)
107
193
  throw new Error("model is required");
108
194
  if (this.onSetModel)
109
195
  await this.onSetModel(provider, model);
110
- return { sessionId, provider, model };
196
+ return { sessionId: this.aliasSessionId(sessionId), provider, model };
111
197
  }
112
198
  /**
113
199
  * Run a prompt to completion and return the ACP `stopReason`.
@@ -122,13 +208,31 @@ export class SessionManager {
122
208
  const session = this.resolveSession(sessionIdHint);
123
209
  session.lastActive = Date.now();
124
210
  session.history.push({ role: "user", content: prompt, createdAt: session.lastActive });
125
- this.store.addMessage(session.id, "user", prompt, "text");
126
211
  this.activeSessionId = session.id;
212
+ // Plugin command short-circuit: if the prompt is a /command match,
213
+ // dispatch straight to the registered handler and skip the
214
+ // AgentRunner entirely. Mirrors OpenClaw `matchPluginCommand` —
215
+ // see `openclaw/src/plugins/commands.ts`. Natural-language prompts
216
+ // ("截屏发给我") flow through unchanged because `match()` only
217
+ // returns hits for `/`-prefixed text.
218
+ if (this.pluginCommands) {
219
+ const matched = this.pluginCommands.match(prompt);
220
+ if (matched) {
221
+ return this.handlePluginCommand(prompt, session.id, matched.command, matched.args);
222
+ }
223
+ }
127
224
  this.notify(session.id, { sessionUpdate: "agent_status", status: "running" });
128
225
  const submitted = this.host.submitTask(prompt, { correlationTag: session.id });
129
226
  this.taskToSession.set(submitted.taskId, session.id);
130
227
  this.currentTask = submitted;
131
228
  session.currentTaskId = submitted.taskId;
229
+ // Persist the user message AFTER taskToSession is wired so a
230
+ // concurrent loadSession from another connection can see this row
231
+ // alongside the upcoming in-flight assistant snapshot.
232
+ // turnId mirrors agent-host's convention (turnId === taskId).
233
+ this.store.appendUserMessage(session.id, prompt, {
234
+ turnId: submitted.taskId,
235
+ });
132
236
  let result;
133
237
  try {
134
238
  result = await submitted.done;
@@ -137,6 +241,10 @@ export class SessionManager {
137
241
  this.currentTask = null;
138
242
  session.currentTaskId = null;
139
243
  this.taskToSession.delete(submitted.taskId);
244
+ // Belt-and-suspenders: in normal flow `task:end` already
245
+ // finalised + cleared the in-flight; defend against the agent
246
+ // crashing without firing task:end.
247
+ this.inFlightByTask.delete(submitted.taskId);
140
248
  this.notify(session.id, { sessionUpdate: "agent_status", status: "idle" });
141
249
  }
142
250
  return { stopReason: result.cancelled ? "cancelled" : "end_turn" };
@@ -147,60 +255,105 @@ export class SessionManager {
147
255
  return false;
148
256
  return this.currentTask.cancel();
149
257
  }
150
- // ── Internal ──────────────────────────────────────────────────────────────
151
258
  /**
152
- * Translate host `AgentEvent`s into ACP `session/update` envelopes.
259
+ * Dispatch a `/command` prompt to its registered handler, bypassing
260
+ * the AgentRunner / LLM. The wire shape stays compatible with the
261
+ * regular prompt path:
262
+ *
263
+ * 1. user message persisted (so reload sees the typed `/screenshot`)
264
+ * 2. `agent_status: running`
265
+ * 3. handler runs — typically emits its own `agent_message_chunk`
266
+ * with a `resource_link` (see `ScreenshotCommandHandler`)
267
+ * 4. `stream_complete` + `agent_status: idle`
153
268
  *
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_对齐`.
269
+ * Errors from the handler are converted into a `system_message_chunk`
270
+ * notice (level=error) instead of crashing the whole session.
189
271
  *
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`.
272
+ * NOTE: assistant turns produced by plugin commands are intentionally
273
+ * NOT persisted — slash command output is time-sensitive (a stale
274
+ * screenshot helps no one) and the user should re-issue the command
275
+ * on reconnect. The user-side `/screenshot` text IS persisted so the
276
+ * conversation log makes sense.
277
+ */
278
+ async handlePluginCommand(prompt, sessionId, command, args) {
279
+ const turnId = randomUUID();
280
+ // Persist user message first so JSONL on disk survives a crash mid-handler.
281
+ this.store.appendUserMessage(sessionId, prompt, { turnId });
282
+ this.notify(sessionId, { sessionUpdate: "agent_status", status: "running" });
283
+ let stopReason = "end_turn";
284
+ let errorText;
285
+ try {
286
+ await command.handle({ sessionId, args });
287
+ }
288
+ catch (e) {
289
+ stopReason = "error";
290
+ errorText = e instanceof Error ? e.message : String(e);
291
+ this.notify(sessionId, {
292
+ sessionUpdate: "system_message_chunk",
293
+ content: { type: "text", text: `${command.name}: ${errorText}` },
294
+ metadata: { kind: "agent_notice", level: "error" },
295
+ _turnId: turnId,
296
+ });
297
+ }
298
+ finally {
299
+ const completeUpdate = {
300
+ sessionUpdate: "stream_complete",
301
+ stopReason,
302
+ _turnId: turnId,
303
+ };
304
+ if (errorText)
305
+ completeUpdate.error = errorText;
306
+ this.notify(sessionId, completeUpdate);
307
+ this.notify(sessionId, { sessionUpdate: "agent_status", status: "idle" });
308
+ }
309
+ return { stopReason };
310
+ }
311
+ // ── Internal ──────────────────────────────────────────────────────────────
312
+ /**
313
+ * Translate host `AgentEvent`s into ACP `session/update` envelopes
314
+ * AND accumulate the assistant turn into the in-memory aggregator
315
+ * for this task. The aggregator is finalised on `task:end` and
316
+ * persisted as a single `assistant_turn` JSONL row — chunk-level
317
+ * persistence is intentionally NOT done (it produces fragmented
318
+ * transcripts that can't be reconstructed cleanly on reload).
195
319
  *
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.
320
+ * Source events:
321
+ * - `task:start` — allocate the in-flight aggregator.
322
+ * - `task:text` — append delta to in-flight + emit
323
+ * `agent_message_chunk`.
324
+ * - `task:reasoning` — emit `agent_thought_chunk` only (NOT
325
+ * persisted — bloats LLM context on resume).
326
+ * - `task:tool` — track in in-flight + emit
327
+ * `tool_call` / `tool_call_update`. Mid-flight
328
+ * progress is forwarded as `in_progress`.
329
+ * - `task:notice` — `warn` is dropped entirely; `error` becomes
330
+ * a `system_message_chunk` with
331
+ * `metadata.kind = "agent_notice"` AND is
332
+ * captured as the in-flight `error` field so
333
+ * the persisted turn carries the failure
334
+ * text.
335
+ * - `task:step` — structural; no ACP emission.
336
+ * - `task:end` — finalise the in-flight, persist a single
337
+ * `assistant_turn` row, emit `stream_complete`
338
+ * BEFORE the closing `agent_status: idle`
339
+ * (chat-store relies on this order).
199
340
  */
200
341
  handleHostEvent(ev) {
201
342
  const sessionId = this.taskToSession.get(ev.taskId);
202
343
  if (!sessionId)
203
344
  return;
345
+ if (ev.type === "task:start") {
346
+ const inflight = InFlightTurnAggregator.start({
347
+ taskId: ev.taskId,
348
+ turnId: ev.turnId,
349
+ // task:start doesn't carry messageId — we'll populate it on
350
+ // the first text/tool/reasoning event below.
351
+ messageId: ev.taskId,
352
+ now: ev.createdAt,
353
+ });
354
+ this.inFlightByTask.set(ev.taskId, inflight);
355
+ return;
356
+ }
204
357
  if (ev.type === "task:text") {
205
358
  if (!ev.text)
206
359
  return;
@@ -210,7 +363,8 @@ export class SessionManager {
210
363
  _turnId: ev.turnId,
211
364
  messageId: ev.messageId,
212
365
  });
213
- this.store.addMessage(sessionId, "assistant", ev.text, "text");
366
+ const inflight = this.ensureInflight(ev.taskId, ev.turnId, ev.messageId);
367
+ inflight.appendText(ev.text);
214
368
  return;
215
369
  }
216
370
  if (ev.type === "task:reasoning") {
@@ -218,9 +372,8 @@ export class SessionManager {
218
372
  // VLM rationale) flow through the dedicated `agent_thought_chunk`
219
373
  // ACP channel so the web UI can render them in the reasoning side
220
374
  // 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.
375
+ // do NOT persist reasoning text — it would bloat the LLM context
376
+ // window on resume and is purely a presentation signal.
224
377
  if (!ev.text)
225
378
  return;
226
379
  this.notify(sessionId, {
@@ -233,6 +386,7 @@ export class SessionManager {
233
386
  }
234
387
  if (ev.type === "task:tool") {
235
388
  const t = ev.tool;
389
+ const inflight = this.ensureInflight(ev.taskId, ev.turnId, ev.messageId);
236
390
  if (t.status === "start") {
237
391
  this.notify(sessionId, {
238
392
  sessionUpdate: "tool_call",
@@ -243,21 +397,9 @@ export class SessionManager {
243
397
  _turnId: ev.turnId,
244
398
  messageId: ev.messageId,
245
399
  });
246
- this.store.addMessage(sessionId, "assistant", t.name, "tool_call", {
247
- toolCallId: t.id,
248
- parameters: t.args,
249
- });
400
+ inflight.noteToolStart({ id: t.id, name: t.name, args: t.args });
250
401
  }
251
402
  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
403
  const output = t.result === undefined
262
404
  ? undefined
263
405
  : typeof t.result === "string"
@@ -273,6 +415,7 @@ export class SessionManager {
273
415
  if (output !== undefined)
274
416
  update.output = output;
275
417
  this.notify(sessionId, update);
418
+ inflight.noteToolUpdate({ id: t.id, ...(output !== undefined ? { output } : {}) });
276
419
  }
277
420
  else if (t.status === "end") {
278
421
  const status = t.isError ? "failed" : "completed";
@@ -285,7 +428,11 @@ export class SessionManager {
285
428
  _turnId: ev.turnId,
286
429
  messageId: ev.messageId,
287
430
  });
288
- this.store.addMessage(sessionId, "assistant", output || `${t.name} ${status}`, "tool_result", { toolCallId: t.id, status });
431
+ inflight.noteToolEnd({
432
+ id: t.id,
433
+ output: output || `${t.name} ${status}`,
434
+ isError: !!t.isError,
435
+ });
289
436
  }
290
437
  return;
291
438
  }
@@ -304,17 +451,7 @@ export class SessionManager {
304
451
  // this kind and merges the notice into the matching
305
452
  // assistant bubble's `metadata.notices` (correlated by
306
453
  // `_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.
454
+ // instead of a centered gray capsule.
318
455
  if (ev.level === "warn")
319
456
  return;
320
457
  if (!ev.text)
@@ -325,38 +462,35 @@ export class SessionManager {
325
462
  _turnId: ev.turnId,
326
463
  metadata: { kind: "agent_notice", level: ev.level },
327
464
  });
465
+ // Capture the terminal text on the in-flight so the persisted
466
+ // assistant_turn record carries an `error` field even when the
467
+ // model produced no final reply.
468
+ const inflight = this.inFlightByTask.get(ev.taskId);
469
+ if (inflight)
470
+ inflight.noteFatalNotice(ev.text);
328
471
  return;
329
472
  }
330
473
  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.
474
+ // Structural event — consumed by HTTP debug. No ACP emission.
337
475
  return;
338
476
  }
339
477
  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
478
  const stopReason = ev.error
356
479
  ? "error"
357
480
  : ev.result.cancelled
358
481
  ? "cancelled"
359
482
  : "end_turn";
483
+ // Finalise + persist BEFORE emitting `stream_complete`. This
484
+ // ordering matters for any concurrent `session/load` that comes
485
+ // in between `stream_complete` and the prompt's idle signal —
486
+ // the JSONL row should be on disk by the time we tell the
487
+ // client the turn is done.
488
+ const inflight = this.inFlightByTask.get(ev.taskId);
489
+ if (inflight) {
490
+ const record = inflight.finalize(stopReason, ev.completedAt);
491
+ this.store.appendAssistantTurn(sessionId, record);
492
+ this.inFlightByTask.delete(ev.taskId);
493
+ }
360
494
  const completeUpdate = {
361
495
  sessionUpdate: "stream_complete",
362
496
  stopReason,
@@ -365,13 +499,13 @@ export class SessionManager {
365
499
  if (ev.error)
366
500
  completeUpdate.error = ev.error;
367
501
  this.notify(sessionId, completeUpdate);
368
- this.store.addMessage(sessionId, "system", `Task ${status}`, "status", {
369
- taskId: ev.taskId,
370
- status,
371
- });
372
502
  }
373
503
  }
374
504
  resolveSession(idHint) {
505
+ if (this.fixedSessionId) {
506
+ this.ensureFixedSession();
507
+ return this.sessions.get(this.fixedSessionId);
508
+ }
375
509
  if (idHint && this.sessions.has(idHint))
376
510
  return this.sessions.get(idHint);
377
511
  if (idHint && this.store.sessionExists(idHint)) {
@@ -384,12 +518,143 @@ export class SessionManager {
384
518
  const { sessionId } = this.newSession();
385
519
  return this.sessions.get(sessionId);
386
520
  }
521
+ /** Map any sid the client passes onto the canonical sid in fixed mode. */
522
+ aliasSessionId(sid) {
523
+ return this.fixedSessionId ?? sid;
524
+ }
525
+ /** Materialise the fixed session in-memory + on disk if it isn't yet. */
526
+ ensureFixedSession() {
527
+ if (!this.fixedSessionId)
528
+ return;
529
+ if (!this.sessions.has(this.fixedSessionId)) {
530
+ this.sessions.set(this.fixedSessionId, newSession(this.fixedSessionId));
531
+ }
532
+ if (!this.store.sessionExists(this.fixedSessionId)) {
533
+ this.store.upsertSession(this.fixedSessionId);
534
+ }
535
+ this.activeSessionId = this.fixedSessionId;
536
+ }
537
+ /**
538
+ * Lazily create an in-flight aggregator for events that arrive
539
+ * without a preceding `task:start` (defensive — agent-host always
540
+ * fires start, but tests + alternate adapters may not).
541
+ */
542
+ ensureInflight(taskId, turnId, messageId) {
543
+ let inflight = this.inFlightByTask.get(taskId);
544
+ if (!inflight) {
545
+ inflight = InFlightTurnAggregator.start({ taskId, turnId, messageId });
546
+ this.inFlightByTask.set(taskId, inflight);
547
+ }
548
+ return inflight;
549
+ }
550
+ findInflightForSession(sessionId) {
551
+ for (const [taskId, sid] of this.taskToSession) {
552
+ if (sid !== sessionId)
553
+ continue;
554
+ const inflight = this.inFlightByTask.get(taskId);
555
+ if (inflight)
556
+ return inflight;
557
+ }
558
+ return null;
559
+ }
560
+ /**
561
+ * Emit a single persisted record as a `history_turn` envelope.
562
+ *
563
+ * Mirrors beeos-claw `bridge-core.replayAssistantTurnAsStream` —
564
+ * `history_turn` is a stamped-done snapshot of an entire turn that
565
+ * the chat-store reducer renders as a complete bubble (no streaming
566
+ * cursor). For assistant turns, we serialise tool calls into the
567
+ * shape `apply-update.buildToolCalls` accepts.
568
+ */
569
+ emitHistoryTurn(sessionId, rec) {
570
+ if (rec.type === "user_message") {
571
+ const update = {
572
+ sessionUpdate: "history_turn",
573
+ role: "user",
574
+ content: rec.content,
575
+ timestamp: new Date(rec.ts).toISOString(),
576
+ };
577
+ if (rec.messageId)
578
+ update.messageId = rec.messageId;
579
+ if (rec.turnId)
580
+ update._turnId = rec.turnId;
581
+ this.notify(sessionId, update);
582
+ return;
583
+ }
584
+ // assistant_turn
585
+ const turn = rec;
586
+ const toolCalls = turn.toolCalls.map((tc) => ({
587
+ id: tc.id,
588
+ name: tc.name,
589
+ arguments: typeof tc.args === "string" ? tc.args : JSON.stringify(tc.args ?? {}),
590
+ status: tc.status,
591
+ ...(tc.output !== undefined ? { output: tc.output } : {}),
592
+ isError: tc.status === "failed",
593
+ }));
594
+ const update = {
595
+ sessionUpdate: "history_turn",
596
+ role: "assistant",
597
+ content: turn.text,
598
+ timestamp: new Date(turn.ts).toISOString(),
599
+ messageId: turn.messageId,
600
+ _turnId: turn.turnId,
601
+ };
602
+ if (toolCalls.length > 0)
603
+ update.toolCalls = toolCalls;
604
+ if (turn.error)
605
+ update.metadata = { error: turn.error };
606
+ this.notify(sessionId, update);
607
+ }
608
+ /**
609
+ * Emit the current state of an in-flight turn as streaming-style
610
+ * envelopes WITHOUT the closing `stream_complete`. The client uses
611
+ * the absence of `stream_complete` to recognise the turn is still
612
+ * generating, matching beeos-claw's `emitCurrentStreamSnapshot`.
613
+ */
614
+ emitInflightSnapshot(sessionId, inflight) {
615
+ const text = inflight.currentText();
616
+ if (text) {
617
+ this.notify(sessionId, {
618
+ sessionUpdate: "agent_message_chunk",
619
+ content: { type: "text", text },
620
+ _turnId: inflight.turnId,
621
+ messageId: inflight.messageId,
622
+ });
623
+ }
624
+ for (const tc of inflight.currentToolCalls()) {
625
+ this.notify(sessionId, {
626
+ sessionUpdate: "tool_call",
627
+ toolCallId: tc.id,
628
+ title: tc.name,
629
+ arguments: JSON.stringify(tc.args),
630
+ status: "in_progress",
631
+ _turnId: inflight.turnId,
632
+ messageId: inflight.messageId,
633
+ });
634
+ if (tc.status !== "in_progress") {
635
+ const update = {
636
+ sessionUpdate: "tool_call_update",
637
+ toolCallId: tc.id,
638
+ status: tc.status,
639
+ _turnId: inflight.turnId,
640
+ messageId: inflight.messageId,
641
+ };
642
+ if (tc.output !== undefined)
643
+ update.output = tc.output;
644
+ this.notify(sessionId, update);
645
+ }
646
+ }
647
+ // Deliberately do NOT emit `stream_complete` — its absence is the
648
+ // signal the client uses to recognise a still-generating turn.
649
+ }
387
650
  notify(sessionId, update) {
388
651
  if (!this.notifier)
389
652
  return;
390
653
  this.notifier.send(sessionId, update);
391
654
  }
392
655
  evictExpired() {
656
+ if (this.fixedSessionId)
657
+ return;
393
658
  const now = Date.now();
394
659
  for (const [id, s] of this.sessions) {
395
660
  if (now - s.lastActive > this.sessionTtlMs) {
@@ -417,9 +682,7 @@ export class SessionManager {
417
682
  * `resource_link` URIs are preserved verbatim on their own line at the
418
683
  * end of the merged prompt, so the Resource Resolution Layer's parsers
419
684
  * (`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.
685
+ * out-of-band channel.
423
686
  */
424
687
  function normalizePrompt(raw) {
425
688
  if (typeof raw === "string")