@robota-sdk/agent-transport 3.0.0-beta.82 → 3.0.0-beta.84

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 (62) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +80 -118
  3. package/dist/browser/client.d.ts +2 -2
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +87 -13
  6. package/dist/browser/index.d.ts.map +1 -1
  7. package/dist/browser/index.js +1 -1
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/observer-messages-CEeisRaF.js +2 -0
  10. package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
  11. package/dist/browser/observer-messages-Dnff2Tdb.d.ts +471 -0
  12. package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
  13. package/dist/node/index.cjs +1 -1
  14. package/dist/node/index.d.cts +308 -16
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +308 -16
  17. package/dist/node/index.d.ts.map +1 -1
  18. package/dist/node/index.js +1 -1
  19. package/dist/node/index.js.map +1 -1
  20. package/dist/node/node.cjs +1 -1
  21. package/dist/node/node.d.cts +69 -1
  22. package/dist/node/node.d.cts.map +1 -1
  23. package/dist/node/node.d.ts +70 -2
  24. package/dist/node/node.d.ts.map +1 -1
  25. package/dist/node/node.js +1 -1
  26. package/dist/node/node.js.map +1 -1
  27. package/package.json +17 -14
  28. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  29. package/src/__tests__/history-attached-budget.test.ts +216 -0
  30. package/src/__tests__/message-decoders.test.ts +496 -6
  31. package/src/__tests__/project-read-messages.test.ts +118 -0
  32. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  33. package/src/__tests__/session-directory-messages.test.ts +322 -0
  34. package/src/__tests__/session-error-classification.test.ts +113 -0
  35. package/src/__tests__/session-history-wire.test.ts +342 -0
  36. package/src/__tests__/session-message-handler-observe.test.ts +217 -0
  37. package/src/__tests__/session-message-handler.test.ts +139 -4
  38. package/src/__tests__/settings-messages.test.ts +193 -0
  39. package/src/__tests__/ws-broadcast-events.test.ts +10 -0
  40. package/src/client.ts +9 -1
  41. package/src/index.ts +18 -2
  42. package/src/loop-control-messages.ts +34 -0
  43. package/src/message-decoders.ts +238 -6
  44. package/src/node/bearer-resource-gate.ts +291 -0
  45. package/src/node/index.ts +13 -0
  46. package/src/observer-messages.ts +47 -0
  47. package/src/open-prompts.ts +76 -0
  48. package/src/outbound-delivery.ts +11 -2
  49. package/src/project-read-messages.ts +74 -0
  50. package/src/protocol-session.ts +23 -1
  51. package/src/session-directory-messages.ts +162 -0
  52. package/src/session-error-classification.ts +91 -0
  53. package/src/session-events.ts +100 -19
  54. package/src/session-message-handler.ts +110 -15
  55. package/src/session-query-messages.ts +123 -5
  56. package/src/session-resume-bridge.ts +8 -0
  57. package/src/settings-messages.ts +103 -0
  58. package/src/wire-messages.ts +238 -11
  59. package/dist/browser/message-decoders-BB8n-H2X.d.ts +0 -252
  60. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
  61. package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
  62. package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
@@ -6,14 +6,22 @@
6
6
  * `SessionResumeBridge` (REMOTE-013 E4 — a SINGLE subscription that outlives per-channel handlers).
7
7
  */
8
8
 
9
+ import { holdOpenPrompts } from './open-prompts.js';
10
+ import { classifySessionErrorForWire } from './session-error-classification.js';
11
+
9
12
  import type { TOutboundDeliver } from './outbound-delivery.js';
10
13
  import type { IProtocolSession } from './protocol-session.js';
14
+ import type { TWireExecutionResult } from './wire-messages.js';
11
15
  import type {
12
16
  IExecutionWorkspaceEvent,
13
17
  TBackgroundJobGroupEvent,
14
18
  TBackgroundTaskEvent,
15
19
  } from '@robota-sdk/agent-interface-execution';
16
- import type { TDriverId, TInteractiveEventName } from '@robota-sdk/agent-interface-session';
20
+ import type {
21
+ TDriverId,
22
+ TInteractiveEventName,
23
+ TTurnSource,
24
+ } from '@robota-sdk/agent-interface-session';
17
25
  import type {
18
26
  IAskRequestEvent,
19
27
  IBranchEvent,
@@ -23,6 +31,8 @@ import type {
23
31
  IPromptResolvedEvent,
24
32
  IPlanApprovalEvent,
25
33
  ISessionRenamedEvent,
34
+ ISessionStatusSnapshot,
35
+ ISessionSwitchedEvent,
26
36
  IToolState,
27
37
  IUiIntentEvent,
28
38
  } from '@robota-sdk/agent-interface-session';
@@ -37,17 +47,17 @@ export const PROTOCOL_SESSION_EVENT_CLASSIFICATION = {
37
47
  thinking: 'forwarded',
38
48
  complete: 'forwarded',
39
49
  error: 'forwarded',
40
- context_update: 'non-surface',
41
- compact: 'non-surface',
50
+ context_update: 'forwarded',
51
+ compact: 'forwarded',
42
52
  interrupted: 'forwarded',
43
- skill_activation: 'non-surface',
53
+ skill_activation: 'forwarded',
44
54
  background_task_event: 'forwarded',
45
55
  background_job_group_event: 'forwarded',
46
56
  execution_workspace_event: 'forwarded',
47
57
  user_message: 'forwarded',
48
- turn_source: 'non-surface',
58
+ turn_source: 'forwarded',
49
59
  context_file_refreshed: 'forwarded',
50
- memory_event: 'non-surface',
60
+ memory_event: 'forwarded',
51
61
  goal_event: 'non-surface',
52
62
  plan_event: 'forwarded',
53
63
  branch_event: 'forwarded',
@@ -57,6 +67,8 @@ export const PROTOCOL_SESSION_EVENT_CLASSIFICATION = {
57
67
  ui_intent: 'requester-routed',
58
68
  session_renamed: 'forwarded',
59
69
  history_cleared: 'forwarded',
70
+ session_switched: 'forwarded',
71
+ status_changed: 'forwarded',
60
72
  } as const satisfies Record<TInteractiveEventName, TProtocolSessionEventClassification>;
61
73
 
62
74
  /**
@@ -72,6 +84,13 @@ export interface ISubscribeSessionEventsOptions {
72
84
  * requester-routed against it (lazy because the resume bridge binds the id only after pairing).
73
85
  */
74
86
  getSurfaceDriverId?: () => TDriverId | undefined;
87
+ /**
88
+ * `false` leaves `permission_request` and `ask_request` unsubscribed. The session parks a prompt
89
+ * only while someone listens for it, so a surface that may not answer must not listen: otherwise an
90
+ * unattended session would hold a prompt nobody can settle instead of failing it closed at once.
91
+ * Defaults to `true`.
92
+ */
93
+ receivePrompts?: boolean;
75
94
  }
76
95
 
77
96
  /**
@@ -89,8 +108,8 @@ export function subscribeSessionEvents(
89
108
  ): () => void {
90
109
  // ARCH-030: no local guard wrapper any more, and no event name passed alongside the message. The name
91
110
  // existed so the local guard could label a failure with it; the boundary labels with `message.type`,
92
- // which is identical for every forwarded event (asserted in `session-event-delivery.test.ts`). Keeping
93
- // the argument would have been a parameter that documents nothing and is read by nobody.
111
+ // the frame that could not be delivered. Keeping the argument would have been a parameter that
112
+ // documents nothing and is read by nobody.
94
113
  // REMOTE-014 E5: stamp the ACTIVE turn's driver id onto TURN-AUTHORED events (co-drive authorship,
95
114
  // display-only), read at emit time. Only these events — background/goal/memory/execution-workspace events
96
115
  // are NOT authored by a driver turn and carry no `driverId`. `undefined` when idle or unattributed.
@@ -109,11 +128,16 @@ export function subscribeSessionEvents(
109
128
  const onThinking = (isThinking: boolean): void =>
110
129
  deliver({ type: 'thinking', isThinking, ...attr() });
111
130
  const onComplete = (result: IExecutionResult): void =>
112
- deliver({ type: 'complete', result, ...attr() });
131
+ deliver({ type: 'complete', result: withoutHistory(result), ...attr() });
113
132
  const onInterrupted = (result: IExecutionResult): void =>
114
- deliver({ type: 'interrupted', result, ...attr() });
133
+ deliver({ type: 'interrupted', result: withoutHistory(result), ...attr() });
115
134
  const onError = (error: Error): void =>
116
- deliver({ type: 'error', message: error.message, ...attr() });
135
+ deliver({
136
+ type: 'error',
137
+ message: error.message,
138
+ ...classifySessionErrorForWire(error),
139
+ ...attr(),
140
+ });
117
141
  const onBackgroundTaskEvent = (event: TBackgroundTaskEvent): void =>
118
142
  deliver({ type: 'background_task_event', event });
119
143
  const onBackgroundJobGroupEvent = (event: TBackgroundJobGroupEvent): void =>
@@ -129,11 +153,23 @@ export function subscribeSessionEvents(
129
153
  const onBranchEvent = (event: IBranchEvent): void => deliver({ type: 'branch_event', event });
130
154
  // REMOTE-007: forward the transport-neutral prompt events so a remote surface can render + answer the
131
155
  // SAME permission/ask prompt; `prompt_resolved` dismisses it when another surface answered first.
132
- const onPermissionRequest = (event: IPermissionRequestEvent): void =>
133
- deliver({ type: 'permission_request', event });
134
- const onAskRequest = (event: IAskRequestEvent): void => deliver({ type: 'ask_request', event });
135
- const onPromptResolved = (event: IPromptResolvedEvent): void =>
156
+ // #3189: kept while open, so a client that attaches later can ask for it with `get-prompts`.
157
+ const receivePrompts = options.receivePrompts ?? true;
158
+ const openPrompts = receivePrompts ? holdOpenPrompts(session) : undefined;
159
+ const onPermissionRequest = (event: IPermissionRequestEvent): void => {
160
+ const frame = { type: 'permission_request', event } as const;
161
+ openPrompts?.record(frame);
162
+ deliver(frame);
163
+ };
164
+ const onAskRequest = (event: IAskRequestEvent): void => {
165
+ const frame = { type: 'ask_request', event } as const;
166
+ openPrompts?.record(frame);
167
+ deliver(frame);
168
+ };
169
+ const onPromptResolved = (event: IPromptResolvedEvent): void => {
170
+ openPrompts?.forget(event.id);
136
171
  deliver({ type: 'prompt_resolved', event });
172
+ };
137
173
  // CMD-004 Stage D: `ui_intent` is REQUESTER-ROUTED — delivered only to the surface whose
138
174
  // server-assigned driver id issued the command. An UNATTRIBUTED intent (no requester id, e.g. an
139
175
  // idle model-invoked command) is unroutable and reaches every surface — never a silent drop.
@@ -151,6 +187,26 @@ export function subscribeSessionEvents(
151
187
  const onSessionRenamed = (event: ISessionRenamedEvent): void =>
152
188
  deliver({ type: 'session_renamed', event });
153
189
  const onHistoryCleared = (): void => deliver({ type: 'history_cleared' });
190
+ // #3189: this surface's session is now another one; it re-reads what it shows. Only surfaces
191
+ // subscribed to the session that moved hear it: a host that binds each connection to its own session
192
+ // sends it to the connection that switched, never to the others.
193
+ const onSessionSwitched = (event: ISessionSwitchedEvent): void => {
194
+ // This connection no longer answers the previous session's prompts; it reads the new session's anew.
195
+ openPrompts?.clear();
196
+ deliver({ type: 'session_switched', event });
197
+ };
198
+ // #3189: what a client that renders the whole session (the TUI) needs beside the streamed turn.
199
+ // The context window is pushed in the frame `get-context` answers with. A compaction, a skill
200
+ // activation or a memory event adds history entries no streamed frame carries, so the client is
201
+ // told the history changed and re-reads it with `get-history`; the entries themselves are not sent.
202
+ const onContextUpdate = (state: ReturnType<IProtocolSession['getContextState']>): void =>
203
+ deliver({ type: 'context', state });
204
+ const onHistoryChanged = (): void => deliver({ type: 'history_changed' });
205
+ const onTurnSource = (source: TTurnSource): void => deliver({ type: 'turn_source', source });
206
+ // One client's `/mode` or `/model` shows on every client on this session, in the frame
207
+ // `get-status` answers with.
208
+ const onStatusChanged = (status: ISessionStatusSnapshot): void =>
209
+ deliver({ type: 'session_status', status });
154
210
 
155
211
  session.on('user_message', onUserMessage);
156
212
  session.on('text_delta', onTextDelta);
@@ -166,12 +222,21 @@ export function subscribeSessionEvents(
166
222
  session.on('plan_event', onPlanEvent);
167
223
  session.on('context_file_refreshed', onContextFileRefreshed);
168
224
  session.on('branch_event', onBranchEvent);
169
- session.on('permission_request', onPermissionRequest);
170
- session.on('ask_request', onAskRequest);
225
+ if (receivePrompts) {
226
+ session.on('permission_request', onPermissionRequest);
227
+ session.on('ask_request', onAskRequest);
228
+ }
171
229
  session.on('prompt_resolved', onPromptResolved);
172
230
  session.on('ui_intent', onUiIntent);
173
231
  session.on('session_renamed', onSessionRenamed);
174
232
  session.on('history_cleared', onHistoryCleared);
233
+ session.on('session_switched', onSessionSwitched);
234
+ session.on('context_update', onContextUpdate);
235
+ session.on('compact', onHistoryChanged);
236
+ session.on('skill_activation', onHistoryChanged);
237
+ session.on('memory_event', onHistoryChanged);
238
+ session.on('turn_source', onTurnSource);
239
+ session.on('status_changed', onStatusChanged);
175
240
 
176
241
  return (): void => {
177
242
  session.off('user_message', onUserMessage);
@@ -188,11 +253,27 @@ export function subscribeSessionEvents(
188
253
  session.off('plan_event', onPlanEvent);
189
254
  session.off('context_file_refreshed', onContextFileRefreshed);
190
255
  session.off('branch_event', onBranchEvent);
191
- session.off('permission_request', onPermissionRequest);
192
- session.off('ask_request', onAskRequest);
256
+ if (receivePrompts) {
257
+ session.off('permission_request', onPermissionRequest);
258
+ session.off('ask_request', onAskRequest);
259
+ }
193
260
  session.off('prompt_resolved', onPromptResolved);
194
261
  session.off('ui_intent', onUiIntent);
195
262
  session.off('session_renamed', onSessionRenamed);
196
263
  session.off('history_cleared', onHistoryCleared);
264
+ session.off('session_switched', onSessionSwitched);
265
+ session.off('context_update', onContextUpdate);
266
+ session.off('compact', onHistoryChanged);
267
+ session.off('skill_activation', onHistoryChanged);
268
+ session.off('memory_event', onHistoryChanged);
269
+ session.off('turn_source', onTurnSource);
270
+ session.off('status_changed', onStatusChanged);
271
+ openPrompts?.release();
197
272
  };
198
273
  }
274
+
275
+ /** The turn's result without the session's history: that only grows, and `get-history` pages it. */
276
+ function withoutHistory(result: IExecutionResult): TWireExecutionResult {
277
+ const { history: _history, ...rest } = result;
278
+ return rest;
279
+ }
@@ -12,17 +12,35 @@ import {
12
12
  handleBackgroundControlMessage,
13
13
  handleBackgroundQueryMessage,
14
14
  } from './background-messages.js';
15
+ import { handleLoopControlMessage, isLoopControlMessage } from './loop-control-messages.js';
15
16
  import { parseClientMessage } from './message-parser.js';
17
+ import { isObserverMessageType } from './observer-messages.js';
18
+ import { handleProjectReadMessage, isProjectReadMessage } from './project-read-messages.js';
19
+ import {
20
+ handleSessionDeleteMessage,
21
+ handleSessionDirectoryMessage,
22
+ handleSessionRenameMessage,
23
+ isSessionDeleteMessage,
24
+ isSessionDirectoryMessage,
25
+ isSessionRenameMessage,
26
+ } from './session-directory-messages.js';
16
27
  import { subscribeSessionEvents } from './session-events.js';
17
- import { handleSessionQueryMessage, isSessionQueryMessage } from './session-query-messages.js';
28
+ import {
29
+ handleSessionQueryMessage,
30
+ isSessionQueryMessage,
31
+ pendingFrame,
32
+ } from './session-query-messages.js';
33
+ import { handleSettingsMessage, isSettingsMessage } from './settings-messages.js';
18
34
  import { handleUsageQueryMessage } from './usage-messages.js';
19
35
 
20
36
  import type { TOutboundDeliver } from './outbound-delivery.js';
21
- import type { IProtocolSession } from './protocol-session.js';
37
+ import type { IProtocolSession, TProjectReadCapableSession } from './protocol-session.js';
38
+ import type { ISettingsReporter } from './settings-messages.js';
22
39
  import type { IUsageQueryReporters } from './usage-messages.js';
23
40
  import type { TClientMessage } from './wire-messages.js';
41
+ import type { TCommandSurfaceLocality } from '@robota-sdk/agent-interface-command';
24
42
  import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
25
- import type { TDriverId } from '@robota-sdk/agent-interface-session';
43
+ import type { ISessionDirectory, TDriverId } from '@robota-sdk/agent-interface-session';
26
44
 
27
45
  // Outbound session→TServerMessage fan-out (incl. CMD-004 requester-routed `ui_intent`) lives in
28
46
  // `session-events.ts`; re-exported here for the bridge and existing importers.
@@ -30,9 +48,16 @@ export { subscribeSessionEvents } from './session-events.js';
30
48
  export type { ISubscribeSessionEventsOptions } from './session-events.js';
31
49
  export { parseClientMessage } from './message-parser.js';
32
50
 
51
+ /**
52
+ * What a connected surface may do. `drive` sends prompts, answers questions and controls the session.
53
+ * `observe` is read-only: it follows this session's conversation and state, but never submits, answers,
54
+ * controls, or reads another session's records, and it never counts as a surface that can answer.
55
+ */
56
+ export type TSessionSurfaceRole = 'drive' | 'observe';
57
+
33
58
  export interface ISessionMessageHandlerOptions {
34
- /** IProtocolSession to expose. */
35
- session: IProtocolSession;
59
+ /** IProtocolSession to expose — `Partial<ISessionProjectRead>` is probed, not assumed (#3282 §4c). */
60
+ session: TProjectReadCapableSession;
36
61
  /**
37
62
  * ARCH-030: the CARRIER's connection-scoped outbound delivery boundary — not a raw `send`, and not a
38
63
  * `send` plus an error callback for this handler to assemble into one. The carrier owns both the sink
@@ -49,12 +74,25 @@ export interface ISessionMessageHandlerOptions {
49
74
  driverId?: TDriverId;
50
75
  /** Trusted carrier-owned product surface, kept separate from driver identity. */
51
76
  surface?: TUsageSurface;
77
+ /** Carrier-decided role of this connection; defaults to `drive`. The client never chooses it here. */
78
+ role?: TSessionSurfaceRole;
52
79
  /** Host-owned cross-session read model. The protocol only correlates and carries its result. */
53
80
  personalUsageReporter?: NonNullable<IUsageQueryReporters['personalUsageReporter']>;
54
81
  /** Host-owned current-session trace/cost producer for the pre-existing message family. */
55
82
  usageReporter?: NonNullable<IUsageQueryReporters['usageReporter']>;
56
83
  /** Host-owned stored-session producer used by cross-session drill-down. */
57
84
  storedSessionUsageReporter?: NonNullable<IUsageQueryReporters['storedSessionUsageReporter']>;
85
+ /** Host-owned session directory (#3189): list, start and switch the host's sessions. */
86
+ sessionDirectory?: ISessionDirectory;
87
+ /** #3282 §4a: host-owned read/write for the GUI Settings screen. */
88
+ settingsReporter?: ISettingsReporter;
89
+ /**
90
+ * #3282 §4 part b-2: carrier-decided, like `role` — whether THIS carrier's connections are
91
+ * provably on this machine. The loopback WS carrier is `'local'`; the device-mesh/WebRTC carrier
92
+ * sets `'remote'`. Absent → `'local'`, forwarded to `executeCommand` and the settings reporter so a
93
+ * command that runs code from outside the session (installing a plugin) can refuse a remote device.
94
+ */
95
+ commandSurfaceLocality?: TCommandSurfaceLocality;
58
96
  }
59
97
 
60
98
  /**
@@ -80,8 +118,10 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
80
118
  onMessage: (data: string) => void;
81
119
  cleanup: () => void;
82
120
  } {
121
+ const role = options.role ?? 'drive';
83
122
  const cleanup = subscribeSessionEvents(options.session, options.deliver, {
84
123
  getSurfaceDriverId: () => options.driverId,
124
+ receivePrompts: role === 'drive',
85
125
  });
86
126
  const onMessage = createMessageHandler(
87
127
  options.session,
@@ -93,22 +133,44 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
93
133
  storedSessionUsageReporter: options.storedSessionUsageReporter,
94
134
  },
95
135
  options.surface,
136
+ role,
137
+ options.sessionDirectory,
138
+ options.settingsReporter,
139
+ options.commandSurfaceLocality,
96
140
  );
97
141
 
98
142
  return { onMessage, cleanup };
99
143
  }
100
144
 
101
145
  function createMessageHandler(
102
- session: IProtocolSession,
146
+ session: TProjectReadCapableSession,
103
147
  deliver: TOutboundDeliver,
104
148
  driverId?: TDriverId,
105
149
  reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
106
150
  surface?: TUsageSurface,
151
+ role: TSessionSurfaceRole = 'drive',
152
+ sessionDirectory?: ISessionDirectory,
153
+ settingsReporter?: ISettingsReporter,
154
+ commandSurfaceLocality?: TCommandSurfaceLocality,
107
155
  ): (data: string) => void {
108
156
  return (data: string): void => {
109
157
  const msg = parseClientMessage(data, deliver);
110
158
  if (!msg) return;
111
- handleClientMessage(session, deliver, msg, driverId, reporters, surface);
159
+ if (role === 'observe' && !isObserverMessageType(msg.type)) {
160
+ deliver({ type: 'protocol_error', message: `Not permitted for an observer: ${msg.type}` });
161
+ return;
162
+ }
163
+ handleClientMessage(
164
+ session,
165
+ deliver,
166
+ msg,
167
+ driverId,
168
+ reporters,
169
+ surface,
170
+ sessionDirectory,
171
+ settingsReporter,
172
+ commandSurfaceLocality,
173
+ );
112
174
  };
113
175
  }
114
176
 
@@ -123,24 +185,51 @@ const EMPTY_USAGE_REPORTERS: IUsageQueryReporters = {
123
185
  * the {@link SessionResumeBridge} intercepts `resume`/`ack` itself and delegates everything else here.
124
186
  */
125
187
  export function handleClientMessage(
126
- session: IProtocolSession,
188
+ session: TProjectReadCapableSession,
127
189
  deliver: TOutboundDeliver,
128
190
  msg: TClientMessage,
129
191
  driverId?: TDriverId,
130
192
  reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
131
193
  surface?: TUsageSurface,
194
+ sessionDirectory?: ISessionDirectory,
195
+ settingsReporter?: ISettingsReporter,
196
+ commandSurfaceLocality?: TCommandSurfaceLocality,
132
197
  ): void {
133
198
  if (handleUsageQueryMessage(session, deliver, msg, reporters)) {
134
199
  return;
135
200
  }
201
+ if (isSessionDirectoryMessage(msg)) {
202
+ handleSessionDirectoryMessage(deliver, msg, sessionDirectory);
203
+ return;
204
+ }
205
+ if (isSettingsMessage(msg)) {
206
+ handleSettingsMessage(session, deliver, msg, settingsReporter, commandSurfaceLocality);
207
+ return;
208
+ }
209
+ if (isSessionRenameMessage(msg)) {
210
+ handleSessionRenameMessage(deliver, msg, sessionDirectory);
211
+ return;
212
+ }
213
+ if (isSessionDeleteMessage(msg)) {
214
+ handleSessionDeleteMessage(deliver, msg, sessionDirectory);
215
+ return;
216
+ }
136
217
  if (isSessionControlMessage(msg)) {
137
- handleSessionControlMessage(session, deliver, msg, driverId, surface);
218
+ handleSessionControlMessage(session, deliver, msg, driverId, surface, commandSurfaceLocality);
219
+ return;
220
+ }
221
+ if (isLoopControlMessage(msg)) {
222
+ handleLoopControlMessage(session, deliver, msg);
138
223
  return;
139
224
  }
140
225
  if (isSessionQueryMessage(msg)) {
141
226
  handleSessionQueryMessage(session, deliver, msg);
142
227
  return;
143
228
  }
229
+ if (isProjectReadMessage(msg)) {
230
+ handleProjectReadMessage(session, deliver, msg);
231
+ return;
232
+ }
144
233
  if (isBackgroundQueryMessage(msg)) {
145
234
  handleBackgroundQueryMessage(session, deliver, msg);
146
235
  return;
@@ -228,6 +317,7 @@ function handleSessionControlMessage(
228
317
  msg: Extract<TClientMessage, { type: 'submit' | 'command' | 'abort' | 'cancel-queue' }>,
229
318
  driverId?: TDriverId,
230
319
  surface?: TUsageSurface,
320
+ commandSurfaceLocality?: TCommandSurfaceLocality,
231
321
  ): void {
232
322
  if (msg.type === 'submit') {
233
323
  // TRANS-008 (issue #2045). A TYPE check, not a falsy one: `{}`, `[]`, `42` and `true` are truthy
@@ -250,17 +340,21 @@ function handleSessionControlMessage(
250
340
  undefined,
251
341
  Object.keys(submitOptions).length > 0 ? submitOptions : undefined,
252
342
  )
253
- .catch((error: Error) => {
254
- deliver({ type: 'protocol_error', message: error.message });
255
- });
343
+ .then(
344
+ // #3189: a prompt submitted during a turn resolves once it is queued. A `get-pending` sent
345
+ // beside the `submit` would be answered before that, so the queue is reported from here.
346
+ () => deliver(pendingFrame(session)),
347
+ (error: Error) => deliver({ type: 'protocol_error', message: error.message }),
348
+ );
256
349
  } else if (msg.type === 'command') {
350
+ const request = msg.requestId !== undefined ? { requestId: msg.requestId } : {};
257
351
  if (!msg.name) {
258
- deliver({ type: 'protocol_error', message: 'name is required' });
352
+ deliver({ type: 'protocol_error', message: 'name is required', ...request });
259
353
  return;
260
354
  }
261
355
  // REMOTE-003: a transport-origin command is tagged `'remote'` (optional policy, allow-by-default;
262
356
  // REMOTE-006). CMD-004: the SERVER-ASSIGNED driver id (E5) is the command origin — intents route back here.
263
- session.executeCommand(msg.name, msg.args ?? '', 'remote', driverId).then(
357
+ session.executeCommand(msg.name, msg.args ?? '', 'remote', driverId, commandSurfaceLocality).then(
264
358
  (result) => {
265
359
  deliver({
266
360
  type: 'command_result',
@@ -268,10 +362,11 @@ function handleSessionControlMessage(
268
362
  message: result?.message ?? `Unknown command: ${msg.name}`,
269
363
  success: result?.success ?? false,
270
364
  data: result?.data,
365
+ ...request,
271
366
  });
272
367
  },
273
368
  (error: Error) => {
274
- deliver({ type: 'protocol_error', message: error.message });
369
+ deliver({ type: 'protocol_error', message: error.message, ...request });
275
370
  },
276
371
  );
277
372
  } else if (msg.type === 'abort') {
@@ -1,22 +1,51 @@
1
+ import { listOpenPrompts } from './open-prompts.js';
2
+
1
3
  import type { TOutboundDeliver } from './outbound-delivery.js';
2
4
  import type { IProtocolSession } from './protocol-session.js';
3
- import type { TClientMessage } from './wire-messages.js';
5
+ import type { IWireHistoryEntry, TClientMessage, TServerMessage } from './wire-messages.js';
4
6
 
5
7
  type TSessionQueryMessage = Extract<
6
8
  TClientMessage,
7
9
  {
8
10
  type:
9
- 'get-messages' | 'get-context' | 'get-executing' | 'get-pending' | 'get-execution-workspace';
11
+ | 'get-messages'
12
+ | 'get-history'
13
+ | 'get-prompts'
14
+ | 'get-context'
15
+ | 'get-commands'
16
+ | 'get-status'
17
+ | 'list-models'
18
+ | 'get-agent-definitions'
19
+ | 'get-executing'
20
+ | 'get-pending'
21
+ | 'get-execution-workspace'
22
+ | 'read-execution-detail';
10
23
  }
11
24
  >;
12
25
 
26
+ /**
27
+ * #3189: the most serialized history one `history` frame carries. Well under the tightest outbound
28
+ * budget (an attached terminal's, 1 MiB), so a client reading page after page is never mistaken for
29
+ * one that stopped reading.
30
+ */
31
+ export const HISTORY_PAGE_MAX_BYTES = 256 * 1024;
32
+
33
+ const utf8 = new TextEncoder();
34
+
13
35
  export function isSessionQueryMessage(msg: TClientMessage): msg is TSessionQueryMessage {
14
36
  return (
15
37
  msg.type === 'get-messages' ||
38
+ msg.type === 'get-history' ||
39
+ msg.type === 'get-prompts' ||
16
40
  msg.type === 'get-context' ||
41
+ msg.type === 'get-commands' ||
42
+ msg.type === 'get-status' ||
43
+ msg.type === 'list-models' ||
44
+ msg.type === 'get-agent-definitions' ||
17
45
  msg.type === 'get-executing' ||
18
46
  msg.type === 'get-pending' ||
19
- msg.type === 'get-execution-workspace'
47
+ msg.type === 'get-execution-workspace' ||
48
+ msg.type === 'read-execution-detail'
20
49
  );
21
50
  }
22
51
 
@@ -26,9 +55,30 @@ export function handleSessionQueryMessage(
26
55
  msg: TSessionQueryMessage,
27
56
  ): void {
28
57
  if (msg.type === 'get-messages') {
29
- deliver({ type: 'messages', messages: session.getMessages() });
58
+ // #3288 §2: `display` rides the same frame as `messages` — a reload/reconnect/resume replay
59
+ // needs both in one round trip, and there is no reason a client would ever want one without
60
+ // the other (the raw messages already have to be fetched to build the projection).
61
+ deliver({ type: 'messages', messages: session.getMessages(), display: session.getMessagesDisplay() });
62
+ } else if (msg.type === 'get-history') {
63
+ deliver(historyPage(session, msg.fromIndex ?? 0));
64
+ } else if (msg.type === 'get-prompts') {
65
+ for (const frame of listOpenPrompts(session)) deliver(frame);
30
66
  } else if (msg.type === 'get-context') {
31
67
  deliver({ type: 'context', state: session.getContextState() });
68
+ } else if (msg.type === 'get-commands') {
69
+ deliver({ type: 'commands', commands: session.listCommands(), skills: session.listSkills() });
70
+ } else if (msg.type === 'get-status') {
71
+ deliver({ type: 'session_status', status: session.getStatusSnapshot() });
72
+ } else if (msg.type === 'list-models') {
73
+ const snapshot = session.listModels();
74
+ deliver({ type: 'model_list', requestId: msg.requestId, ...snapshot });
75
+ } else if (msg.type === 'get-agent-definitions') {
76
+ deliver({
77
+ type: 'agent_definitions',
78
+ requestId: msg.requestId,
79
+ agents: session.listAgentDefinitions(),
80
+ current: session.getDefaultAgentType(),
81
+ });
32
82
  } else if (msg.type === 'get-executing') {
33
83
  deliver({ type: 'executing', executing: session.isExecuting() });
34
84
  } else if (msg.type === 'get-execution-workspace') {
@@ -36,7 +86,75 @@ export function handleSessionQueryMessage(
36
86
  type: 'execution_workspace_event',
37
87
  snapshot: session.getExecutionWorkspaceSnapshot(),
38
88
  });
89
+ } else if (msg.type === 'read-execution-detail') {
90
+ readExecutionDetail(session, deliver, msg);
39
91
  } else {
40
- deliver({ type: 'pending', pending: session.getPendingPrompt() });
92
+ deliver(pendingFrame(session));
93
+ }
94
+ }
95
+
96
+ /** One page of an execution-workspace entry's detail, answered under the request's id. */
97
+ function readExecutionDetail(
98
+ session: IProtocolSession,
99
+ deliver: TOutboundDeliver,
100
+ msg: Extract<TClientMessage, { type: 'read-execution-detail' }>,
101
+ ): void {
102
+ const { requestId } = msg;
103
+ session.readExecutionWorkspaceDetail(msg.entryId, msg.cursor).then(
104
+ (page) => deliver({ type: 'execution_detail', requestId, page }),
105
+ (error: unknown) =>
106
+ deliver({
107
+ type: 'execution_detail_error',
108
+ requestId,
109
+ message: error instanceof Error ? error.message : String(error),
110
+ }),
111
+ );
112
+ }
113
+
114
+ /** The next queued prompt and how many wait, as the session reports them now. */
115
+ export function pendingFrame(
116
+ session: IProtocolSession,
117
+ ): Extract<TServerMessage, { type: 'pending' }> {
118
+ return {
119
+ type: 'pending',
120
+ pending: session.getPendingPrompt(),
121
+ pendingCount: session.getPendingCount(),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * The history from `fromIndex`, as many entries as fit {@link HISTORY_PAGE_MAX_BYTES}. At least one
127
+ * entry is sent while any remain, so a client always moves forward, even past one larger entry.
128
+ */
129
+ function historyPage(
130
+ session: IProtocolSession,
131
+ fromIndex: number,
132
+ ): Extract<TServerMessage, { type: 'history' }> {
133
+ const history = session.getFullHistory();
134
+ const total = history.length;
135
+ const startIndex = Math.min(fromIndex, total);
136
+ const entries: IWireHistoryEntry[] = [];
137
+ let bytes = 0;
138
+ for (let index = startIndex; index < total; index += 1) {
139
+ const entry = toWireHistoryEntry(history[index]!);
140
+ const size = utf8.encode(JSON.stringify(entry)).byteLength;
141
+ if (entries.length > 0 && bytes + size > HISTORY_PAGE_MAX_BYTES) break;
142
+ entries.push(entry);
143
+ bytes += size;
41
144
  }
145
+ return { type: 'history', startIndex, total, entries };
146
+ }
147
+
148
+ function toWireHistoryEntry(
149
+ entry: ReturnType<IProtocolSession['getFullHistory']>[number],
150
+ ): IWireHistoryEntry {
151
+ return { ...entry, timestamp: toIsoTimestamp(entry.timestamp) };
152
+ }
153
+
154
+ /**
155
+ * The declared type is `Date`, but a resumed session's entries were read back from JSON unrevived, so
156
+ * their `timestamp` is already the ISO string the store wrote. Either way the wire carries that string.
157
+ */
158
+ function toIsoTimestamp(timestamp: Date | string): string {
159
+ return typeof timestamp === 'string' ? timestamp : timestamp.toISOString();
42
160
  }
@@ -28,6 +28,7 @@ import type { TOutboundDeliver } from './outbound-delivery.js';
28
28
  import type { IProtocolSession } from './protocol-session.js';
29
29
  import type { IUsageQueryReporters } from './usage-messages.js';
30
30
  import type { TSeqServerMessage, TServerMessage } from './wire-messages.js';
31
+ import type { TCommandSurfaceLocality } from '@robota-sdk/agent-interface-command';
31
32
  import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
32
33
  import type { TDriverId } from '@robota-sdk/agent-interface-session';
33
34
 
@@ -65,6 +66,8 @@ export interface ISessionResumeBridgeOptions {
65
66
  readonly personalUsageReporter?: IUsageQueryReporters['personalUsageReporter'];
66
67
  readonly usageReporter?: IUsageQueryReporters['usageReporter'];
67
68
  readonly storedSessionUsageReporter?: IUsageQueryReporters['storedSessionUsageReporter'];
69
+ /** #3282 §4 part b-2: this bridge is the reconnectable WebRTC path, so it is always `'remote'`. */
70
+ readonly commandSurfaceLocality?: TCommandSurfaceLocality;
68
71
  }
69
72
 
70
73
  export class SessionResumeBridge {
@@ -73,6 +76,7 @@ export class SessionResumeBridge {
73
76
  private driverId?: TDriverId;
74
77
  private readonly reporters: IUsageQueryReporters;
75
78
  private readonly surface?: TUsageSurface;
79
+ private readonly commandSurfaceLocality?: TCommandSurfaceLocality;
76
80
  private readonly unsubscribe: () => void;
77
81
  private onDeliveryError?: IAttachOptions['onDeliveryError'];
78
82
  /**
@@ -105,6 +109,7 @@ export class SessionResumeBridge {
105
109
  this.buffer = new ResumeBuffer(options.buffer);
106
110
  this.driverId = options.driverId;
107
111
  this.surface = options.surface;
112
+ this.commandSurfaceLocality = options.commandSurfaceLocality;
108
113
  this.reporters = {
109
114
  personalUsageReporter: options.personalUsageReporter,
110
115
  usageReporter: options.usageReporter,
@@ -178,6 +183,9 @@ export class SessionResumeBridge {
178
183
  this.driverId,
179
184
  this.reporters,
180
185
  this.surface,
186
+ undefined,
187
+ undefined,
188
+ this.commandSurfaceLocality,
181
189
  );
182
190
  }
183
191