@robota-sdk/agent-transport 3.0.0-beta.83 → 3.0.0-beta.85

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 (45) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +80 -118
  3. package/dist/browser/client.d.ts +1 -1
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +60 -6
  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-DC_8VHk9.d.ts → observer-messages-Dnff2Tdb.d.ts} +125 -3
  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 +181 -6
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +181 -6
  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/package.json +12 -11
  21. package/src/__tests__/handoff-chunking.test.ts +44 -1
  22. package/src/__tests__/message-decoders.test.ts +295 -1
  23. package/src/__tests__/project-read-messages.test.ts +118 -0
  24. package/src/__tests__/session-directory-messages.test.ts +130 -0
  25. package/src/__tests__/session-error-classification.test.ts +113 -0
  26. package/src/__tests__/session-message-handler-observe.test.ts +25 -1
  27. package/src/__tests__/session-message-handler.test.ts +57 -3
  28. package/src/__tests__/settings-messages.test.ts +193 -0
  29. package/src/handoff-chunking.ts +52 -12
  30. package/src/index.ts +4 -0
  31. package/src/message-decoders.ts +157 -3
  32. package/src/observer-messages.ts +11 -0
  33. package/src/project-read-messages.ts +74 -0
  34. package/src/protocol-session.ts +17 -1
  35. package/src/session-directory-messages.ts +78 -1
  36. package/src/session-error-classification.ts +91 -0
  37. package/src/session-events.ts +7 -1
  38. package/src/session-message-handler.ts +58 -8
  39. package/src/session-query-messages.ts +18 -1
  40. package/src/session-resume-bridge.ts +8 -0
  41. package/src/settings-messages.ts +103 -0
  42. package/src/wire-messages.ts +133 -4
  43. package/dist/browser/observer-messages-Bx-xReUQ.js +0 -2
  44. package/dist/browser/observer-messages-Bx-xReUQ.js.map +0 -1
  45. package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +0 -1
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What the `error` wire frame should say about a session failure, beyond its raw `message` (#3289
3
+ * §3). A renderer maps `code` to one plain sentence with the next step, keeping `message` behind a
4
+ * "Details" disclosure — see `agent-ui-web`'s `SessionNotices`.
5
+ *
6
+ * Walks wrapped layers (`originalError`/`cause`, a few deep) because a higher-level catch can wrap the
7
+ * provider's own typed error in another one (e.g. a "Conversation failed" `ProviderError`) before it
8
+ * reaches here — the same reason `agent-core`'s `classifyProviderFailure` walks layers rather than
9
+ * looking at the outermost error alone.
10
+ */
11
+
12
+ import {
13
+ AuthenticationError,
14
+ ModelNotAvailableError,
15
+ NetworkError,
16
+ ProviderError,
17
+ RateLimitError,
18
+ } from '@robota-sdk/agent-core';
19
+
20
+ /** Kept in sync with `wire-messages.ts`'s `error` variant — see that type for what each means. */
21
+ export type TSessionErrorCode =
22
+ 'auth' | 'rate_limit' | 'model_unavailable' | 'network' | 'provider';
23
+
24
+ export interface ISessionErrorClassification {
25
+ code?: TSessionErrorCode;
26
+ provider?: string;
27
+ retryAfterSeconds?: number;
28
+ /** For `code: 'model_unavailable'`, the model the request tried, when the failure named one. */
29
+ model?: string;
30
+ }
31
+
32
+ const MAX_WRAP_DEPTH = 8;
33
+
34
+ /** The error and everything it wraps, outermost first, followed to a small depth and never twice. */
35
+ function layersOf(error: unknown): unknown[] {
36
+ const layers: unknown[] = [];
37
+ const seen = new Set<unknown>();
38
+ let frontier: unknown[] = [error];
39
+ for (let depth = 0; depth <= MAX_WRAP_DEPTH && frontier.length > 0; depth++) {
40
+ const next: unknown[] = [];
41
+ for (const layer of frontier) {
42
+ if (layer === undefined || layer === null || seen.has(layer)) continue;
43
+ seen.add(layer);
44
+ layers.push(layer);
45
+ if (typeof layer === 'object') {
46
+ const record = layer as Record<string, unknown>;
47
+ next.push(record['originalError'], record['cause']);
48
+ }
49
+ }
50
+ frontier = next;
51
+ }
52
+ return layers;
53
+ }
54
+
55
+ /**
56
+ * Classify a session failure for the wire, or leave it uncoded when it names no provider at all —
57
+ * an unrelated internal failure keeps showing its raw `message` exactly as before this classification
58
+ * existed.
59
+ */
60
+ export function classifySessionErrorForWire(error: unknown): ISessionErrorClassification {
61
+ // A generic ProviderError never wins outright — a wrapper (e.g. "Conversation failed") can sit
62
+ // above the real, more specific cause, so it is kept only as a fallback while deeper layers are
63
+ // still searched for one of the specific types below.
64
+ let fallback: ISessionErrorClassification | undefined;
65
+ for (const layer of layersOf(error)) {
66
+ if (layer instanceof AuthenticationError) {
67
+ return { code: 'auth', ...(layer.provider !== undefined && { provider: layer.provider }) };
68
+ }
69
+ if (layer instanceof RateLimitError) {
70
+ return {
71
+ code: 'rate_limit',
72
+ ...(layer.provider !== undefined && { provider: layer.provider }),
73
+ ...(layer.retryAfter !== undefined && { retryAfterSeconds: layer.retryAfter }),
74
+ };
75
+ }
76
+ if (layer instanceof ModelNotAvailableError) {
77
+ return {
78
+ code: 'model_unavailable',
79
+ provider: layer.provider,
80
+ ...(layer.model !== undefined && { model: layer.model }),
81
+ };
82
+ }
83
+ if (layer instanceof NetworkError) {
84
+ return { code: 'network', ...(layer.provider !== undefined && { provider: layer.provider }) };
85
+ }
86
+ if (fallback === undefined && layer instanceof ProviderError) {
87
+ fallback = { code: 'provider', provider: layer.provider };
88
+ }
89
+ }
90
+ return fallback ?? {};
91
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { holdOpenPrompts } from './open-prompts.js';
10
+ import { classifySessionErrorForWire } from './session-error-classification.js';
10
11
 
11
12
  import type { TOutboundDeliver } from './outbound-delivery.js';
12
13
  import type { IProtocolSession } from './protocol-session.js';
@@ -131,7 +132,12 @@ export function subscribeSessionEvents(
131
132
  const onInterrupted = (result: IExecutionResult): void =>
132
133
  deliver({ type: 'interrupted', result: withoutHistory(result), ...attr() });
133
134
  const onError = (error: Error): void =>
134
- deliver({ type: 'error', message: error.message, ...attr() });
135
+ deliver({
136
+ type: 'error',
137
+ message: error.message,
138
+ ...classifySessionErrorForWire(error),
139
+ ...attr(),
140
+ });
135
141
  const onBackgroundTaskEvent = (event: TBackgroundTaskEvent): void =>
136
142
  deliver({ type: 'background_task_event', event });
137
143
  const onBackgroundJobGroupEvent = (event: TBackgroundJobGroupEvent): void =>
@@ -15,9 +15,14 @@ import {
15
15
  import { handleLoopControlMessage, isLoopControlMessage } from './loop-control-messages.js';
16
16
  import { parseClientMessage } from './message-parser.js';
17
17
  import { isObserverMessageType } from './observer-messages.js';
18
+ import { handleProjectReadMessage, isProjectReadMessage } from './project-read-messages.js';
18
19
  import {
20
+ handleSessionDeleteMessage,
19
21
  handleSessionDirectoryMessage,
22
+ handleSessionRenameMessage,
23
+ isSessionDeleteMessage,
20
24
  isSessionDirectoryMessage,
25
+ isSessionRenameMessage,
21
26
  } from './session-directory-messages.js';
22
27
  import { subscribeSessionEvents } from './session-events.js';
23
28
  import {
@@ -25,12 +30,15 @@ import {
25
30
  isSessionQueryMessage,
26
31
  pendingFrame,
27
32
  } from './session-query-messages.js';
33
+ import { handleSettingsMessage, isSettingsMessage } from './settings-messages.js';
28
34
  import { handleUsageQueryMessage } from './usage-messages.js';
29
35
 
30
36
  import type { TOutboundDeliver } from './outbound-delivery.js';
31
- import type { IProtocolSession } from './protocol-session.js';
37
+ import type { IProtocolSession, TProjectReadCapableSession } from './protocol-session.js';
38
+ import type { ISettingsReporter } from './settings-messages.js';
32
39
  import type { IUsageQueryReporters } from './usage-messages.js';
33
40
  import type { TClientMessage } from './wire-messages.js';
41
+ import type { TCommandSurfaceLocality } from '@robota-sdk/agent-interface-command';
34
42
  import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
35
43
  import type { ISessionDirectory, TDriverId } from '@robota-sdk/agent-interface-session';
36
44
 
@@ -48,8 +56,8 @@ export { parseClientMessage } from './message-parser.js';
48
56
  export type TSessionSurfaceRole = 'drive' | 'observe';
49
57
 
50
58
  export interface ISessionMessageHandlerOptions {
51
- /** IProtocolSession to expose. */
52
- session: IProtocolSession;
59
+ /** IProtocolSession to expose — `Partial<ISessionProjectRead>` is probed, not assumed (#3282 §4c). */
60
+ session: TProjectReadCapableSession;
53
61
  /**
54
62
  * ARCH-030: the CARRIER's connection-scoped outbound delivery boundary — not a raw `send`, and not a
55
63
  * `send` plus an error callback for this handler to assemble into one. The carrier owns both the sink
@@ -76,6 +84,15 @@ export interface ISessionMessageHandlerOptions {
76
84
  storedSessionUsageReporter?: NonNullable<IUsageQueryReporters['storedSessionUsageReporter']>;
77
85
  /** Host-owned session directory (#3189): list, start and switch the host's sessions. */
78
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;
79
96
  }
80
97
 
81
98
  /**
@@ -118,19 +135,23 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
118
135
  options.surface,
119
136
  role,
120
137
  options.sessionDirectory,
138
+ options.settingsReporter,
139
+ options.commandSurfaceLocality,
121
140
  );
122
141
 
123
142
  return { onMessage, cleanup };
124
143
  }
125
144
 
126
145
  function createMessageHandler(
127
- session: IProtocolSession,
146
+ session: TProjectReadCapableSession,
128
147
  deliver: TOutboundDeliver,
129
148
  driverId?: TDriverId,
130
149
  reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
131
150
  surface?: TUsageSurface,
132
151
  role: TSessionSurfaceRole = 'drive',
133
152
  sessionDirectory?: ISessionDirectory,
153
+ settingsReporter?: ISettingsReporter,
154
+ commandSurfaceLocality?: TCommandSurfaceLocality,
134
155
  ): (data: string) => void {
135
156
  return (data: string): void => {
136
157
  const msg = parseClientMessage(data, deliver);
@@ -139,7 +160,17 @@ function createMessageHandler(
139
160
  deliver({ type: 'protocol_error', message: `Not permitted for an observer: ${msg.type}` });
140
161
  return;
141
162
  }
142
- handleClientMessage(session, deliver, msg, driverId, reporters, surface, sessionDirectory);
163
+ handleClientMessage(
164
+ session,
165
+ deliver,
166
+ msg,
167
+ driverId,
168
+ reporters,
169
+ surface,
170
+ sessionDirectory,
171
+ settingsReporter,
172
+ commandSurfaceLocality,
173
+ );
143
174
  };
144
175
  }
145
176
 
@@ -154,13 +185,15 @@ const EMPTY_USAGE_REPORTERS: IUsageQueryReporters = {
154
185
  * the {@link SessionResumeBridge} intercepts `resume`/`ack` itself and delegates everything else here.
155
186
  */
156
187
  export function handleClientMessage(
157
- session: IProtocolSession,
188
+ session: TProjectReadCapableSession,
158
189
  deliver: TOutboundDeliver,
159
190
  msg: TClientMessage,
160
191
  driverId?: TDriverId,
161
192
  reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
162
193
  surface?: TUsageSurface,
163
194
  sessionDirectory?: ISessionDirectory,
195
+ settingsReporter?: ISettingsReporter,
196
+ commandSurfaceLocality?: TCommandSurfaceLocality,
164
197
  ): void {
165
198
  if (handleUsageQueryMessage(session, deliver, msg, reporters)) {
166
199
  return;
@@ -169,8 +202,20 @@ export function handleClientMessage(
169
202
  handleSessionDirectoryMessage(deliver, msg, sessionDirectory);
170
203
  return;
171
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
+ }
172
217
  if (isSessionControlMessage(msg)) {
173
- handleSessionControlMessage(session, deliver, msg, driverId, surface);
218
+ handleSessionControlMessage(session, deliver, msg, driverId, surface, commandSurfaceLocality);
174
219
  return;
175
220
  }
176
221
  if (isLoopControlMessage(msg)) {
@@ -181,6 +226,10 @@ export function handleClientMessage(
181
226
  handleSessionQueryMessage(session, deliver, msg);
182
227
  return;
183
228
  }
229
+ if (isProjectReadMessage(msg)) {
230
+ handleProjectReadMessage(session, deliver, msg);
231
+ return;
232
+ }
184
233
  if (isBackgroundQueryMessage(msg)) {
185
234
  handleBackgroundQueryMessage(session, deliver, msg);
186
235
  return;
@@ -268,6 +317,7 @@ function handleSessionControlMessage(
268
317
  msg: Extract<TClientMessage, { type: 'submit' | 'command' | 'abort' | 'cancel-queue' }>,
269
318
  driverId?: TDriverId,
270
319
  surface?: TUsageSurface,
320
+ commandSurfaceLocality?: TCommandSurfaceLocality,
271
321
  ): void {
272
322
  if (msg.type === 'submit') {
273
323
  // TRANS-008 (issue #2045). A TYPE check, not a falsy one: `{}`, `[]`, `42` and `true` are truthy
@@ -304,7 +354,7 @@ function handleSessionControlMessage(
304
354
  }
305
355
  // REMOTE-003: a transport-origin command is tagged `'remote'` (optional policy, allow-by-default;
306
356
  // REMOTE-006). CMD-004: the SERVER-ASSIGNED driver id (E5) is the command origin — intents route back here.
307
- session.executeCommand(msg.name, msg.args ?? '', 'remote', driverId).then(
357
+ session.executeCommand(msg.name, msg.args ?? '', 'remote', driverId, commandSurfaceLocality).then(
308
358
  (result) => {
309
359
  deliver({
310
360
  type: 'command_result',
@@ -14,6 +14,8 @@ type TSessionQueryMessage = Extract<
14
14
  | 'get-context'
15
15
  | 'get-commands'
16
16
  | 'get-status'
17
+ | 'list-models'
18
+ | 'get-agent-definitions'
17
19
  | 'get-executing'
18
20
  | 'get-pending'
19
21
  | 'get-execution-workspace'
@@ -38,6 +40,8 @@ export function isSessionQueryMessage(msg: TClientMessage): msg is TSessionQuery
38
40
  msg.type === 'get-context' ||
39
41
  msg.type === 'get-commands' ||
40
42
  msg.type === 'get-status' ||
43
+ msg.type === 'list-models' ||
44
+ msg.type === 'get-agent-definitions' ||
41
45
  msg.type === 'get-executing' ||
42
46
  msg.type === 'get-pending' ||
43
47
  msg.type === 'get-execution-workspace' ||
@@ -51,7 +55,10 @@ export function handleSessionQueryMessage(
51
55
  msg: TSessionQueryMessage,
52
56
  ): void {
53
57
  if (msg.type === 'get-messages') {
54
- 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() });
55
62
  } else if (msg.type === 'get-history') {
56
63
  deliver(historyPage(session, msg.fromIndex ?? 0));
57
64
  } else if (msg.type === 'get-prompts') {
@@ -62,6 +69,16 @@ export function handleSessionQueryMessage(
62
69
  deliver({ type: 'commands', commands: session.listCommands(), skills: session.listSkills() });
63
70
  } else if (msg.type === 'get-status') {
64
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
+ });
65
82
  } else if (msg.type === 'get-executing') {
66
83
  deliver({ type: 'executing', executing: session.isExecuting() });
67
84
  } else if (msg.type === 'get-execution-workspace') {
@@ -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
 
@@ -0,0 +1,103 @@
1
+ import type { TOutboundDeliver } from './outbound-delivery.js';
2
+ import type { IProtocolSession } from './protocol-session.js';
3
+ import type { TClientMessage } from './wire-messages.js';
4
+ import type { TCommandSurfaceLocality } from '@robota-sdk/agent-interface-command';
5
+ import type { ISettingsSnapshot, TSettingsPatch } from '@robota-sdk/agent-interface-session';
6
+
7
+ type TSettingsMessage = Extract<TClientMessage, { type: 'get-settings' | 'update-settings' }>;
8
+
9
+ export function isSettingsMessage(msg: TClientMessage): msg is TSettingsMessage {
10
+ return msg.type === 'get-settings' || msg.type === 'update-settings';
11
+ }
12
+
13
+ /** What applying a patch did: the fresh snapshot, or why nothing was written. */
14
+ export type TSettingsUpdateOutcome =
15
+ | { readonly ok: true; readonly settings: ISettingsSnapshot }
16
+ | {
17
+ readonly ok: false;
18
+ readonly code: 'not_available' | 'invalid' | 'refused' | 'update_failed';
19
+ readonly message: string;
20
+ };
21
+
22
+ /**
23
+ * #3282 §4a: host-owned read/write for the Settings screen, supplied by the composition root the
24
+ * same way `IUsageQueryReporters` supplies usage reads — this package only correlates the request
25
+ * and carries the result. `updateSettings` applies through the SAME function the matching slash
26
+ * command uses (issue #3282 §4: "one code path, so the behavior matches the TUI"), so whatever a
27
+ * command would refuse on this surface is refused here too.
28
+ */
29
+ export interface ISettingsReporter {
30
+ getSettings(
31
+ session: IProtocolSession,
32
+ locality?: TCommandSurfaceLocality,
33
+ ): ISettingsSnapshot | Promise<ISettingsSnapshot>;
34
+ /**
35
+ * Applies through the SAME function the matching slash command uses. `locality` (#3282 §4 part
36
+ * b-2) is forwarded to that command exactly as the transport received it, so a command that
37
+ * refuses a remote surface (installing a plugin) refuses identically here.
38
+ */
39
+ updateSettings(
40
+ session: IProtocolSession,
41
+ patch: TSettingsPatch,
42
+ locality?: TCommandSurfaceLocality,
43
+ ): TSettingsUpdateOutcome | Promise<TSettingsUpdateOutcome>;
44
+ }
45
+
46
+ export function handleSettingsMessage(
47
+ session: IProtocolSession,
48
+ deliver: TOutboundDeliver,
49
+ msg: TSettingsMessage,
50
+ reporter: ISettingsReporter | undefined,
51
+ locality?: TCommandSurfaceLocality,
52
+ ): void {
53
+ if (!reporter) {
54
+ deliverSettingsError(
55
+ deliver,
56
+ msg.requestId,
57
+ 'not_available',
58
+ 'Settings are not available on this host.',
59
+ );
60
+ return;
61
+ }
62
+ try {
63
+ if (msg.type === 'get-settings') {
64
+ void Promise.resolve(reporter.getSettings(session, locality)).then(
65
+ (settings) => deliver({ type: 'settings', requestId: msg.requestId, settings }),
66
+ (error) =>
67
+ deliverSettingsError(deliver, msg.requestId, 'update_failed', failureMessage(error)),
68
+ );
69
+ return;
70
+ }
71
+ void Promise.resolve(reporter.updateSettings(session, msg.patch, locality)).then(
72
+ (outcome) => {
73
+ if (outcome.ok) {
74
+ deliver({ type: 'settings', requestId: msg.requestId, settings: outcome.settings });
75
+ } else {
76
+ deliverSettingsError(deliver, msg.requestId, outcome.code, outcome.message);
77
+ }
78
+ },
79
+ (error) =>
80
+ deliverSettingsError(deliver, msg.requestId, 'update_failed', failureMessage(error)),
81
+ );
82
+ } catch (error) {
83
+ deliverSettingsError(
84
+ deliver,
85
+ msg.requestId,
86
+ 'update_failed',
87
+ failureMessage(error instanceof Error ? error : String(error)),
88
+ );
89
+ }
90
+ }
91
+
92
+ function deliverSettingsError(
93
+ deliver: TOutboundDeliver,
94
+ requestId: string,
95
+ code: 'not_available' | 'invalid' | 'refused' | 'update_failed',
96
+ message: string,
97
+ ): void {
98
+ deliver({ type: 'settings_error', requestId, code, message });
99
+ }
100
+
101
+ function failureMessage(error: Error | string): string {
102
+ return error instanceof Error ? error.message : String(error);
103
+ }