@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
@@ -56,6 +56,39 @@ export interface IHandoffChunk {
56
56
  */
57
57
  export const DEFAULT_MAX_CHUNK_BYTES = 16 * 1024;
58
58
 
59
+ // This module is exported from the package root, which also builds for the browser, so it uses the
60
+ // web platform's bytes, text codecs and base64 (`btoa`/`atob`) rather than Node's `Buffer`.
61
+
62
+ /** How many bytes go through `String.fromCharCode` at once, well under the argument-count limit. */
63
+ const BINARY_STRING_STEP = 0x8000;
64
+
65
+ function toBase64(bytes: Uint8Array): string {
66
+ let binary = '';
67
+ for (let offset = 0; offset < bytes.length; offset += BINARY_STRING_STEP) {
68
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + BINARY_STRING_STEP));
69
+ }
70
+ return btoa(binary);
71
+ }
72
+
73
+ /** The bytes `data` encodes, or `undefined` when it is not base64 at all. */
74
+ function fromBase64(data: string): Uint8Array | undefined {
75
+ let binary: string;
76
+ try {
77
+ binary = atob(data);
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ const bytes = new Uint8Array(binary.length);
82
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
83
+ return bytes;
84
+ }
85
+
86
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
87
+ if (a.length !== b.length) return false;
88
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
89
+ return true;
90
+ }
91
+
59
92
  /**
60
93
  * Cut a sealed payload into chunks.
61
94
  *
@@ -75,14 +108,14 @@ export function chunkHandoffPayload(
75
108
  'as a hang rather than as the configuration error it is.',
76
109
  );
77
110
  }
78
- const bytes = Buffer.from(serialized, 'utf8');
111
+ const bytes = new TextEncoder().encode(serialized);
79
112
  // An empty payload is still one chunk. Zero chunks would leave the receiver unable to tell a
80
113
  // completed empty transfer from one that never started.
81
114
  const total = Math.max(1, Math.ceil(bytes.byteLength / maxChunkBytes));
82
115
  const chunks: IHandoffChunk[] = [];
83
116
  for (let index = 0; index < total; index += 1) {
84
117
  const slice = bytes.subarray(index * maxChunkBytes, (index + 1) * maxChunkBytes);
85
- chunks.push({ handoffId, index, total, data: slice.toString('base64') });
118
+ chunks.push({ handoffId, index, total, data: toBase64(slice) });
86
119
  }
87
120
  return chunks;
88
121
  }
@@ -127,7 +160,7 @@ export interface IChunkResult {
127
160
  * would make this a dispatcher as well as a buffer.
128
161
  */
129
162
  export class HandoffChunkAssembler {
130
- private readonly chunks = new Map<number, Buffer>();
163
+ private readonly chunks = new Map<number, Uint8Array>();
131
164
  private declaredTotal: number | undefined;
132
165
 
133
166
  constructor(private readonly handoffId: string) {}
@@ -161,19 +194,19 @@ export class HandoffChunkAssembler {
161
194
  return refuse('out-of-range');
162
195
  }
163
196
 
164
- const decoded = Buffer.from(chunk.data, 'base64');
165
- // Buffer.from is lenient — it drops what it cannot parse rather than throwing, so a mangled
166
- // chunk would silently become a shorter one and corrupt the payload in a way only the final
167
- // digest would catch, after the whole transfer completed. Re-encoding is how the leniency is
168
- // detected here instead.
169
- if (decoded.toString('base64') !== chunk.data) return refuse('undecodable');
197
+ const decoded = fromBase64(chunk.data);
198
+ // Base64 decoding is lenient — it skips whitespace and accepts non-canonical padding — so a
199
+ // mangled chunk could silently become a different one and corrupt the payload in a way only the
200
+ // final digest would catch, after the whole transfer completed. Re-encoding is how the leniency
201
+ // is detected here instead.
202
+ if (decoded === undefined || toBase64(decoded) !== chunk.data) return refuse('undecodable');
170
203
 
171
204
  this.declaredTotal = chunk.total;
172
205
  const held = this.chunks.get(chunk.index);
173
206
  if (held !== undefined) {
174
207
  // A retry re-sending the same bytes is normal. Different bytes for the same index is the
175
208
  // inconsistent case again, and the same reasoning applies.
176
- if (held.equals(decoded)) {
209
+ if (sameBytes(held, decoded)) {
177
210
  return { outcome: 'duplicate', received: this.chunks.size, expected: chunk.total };
178
211
  }
179
212
  return refuse('inconsistent-total');
@@ -206,7 +239,7 @@ export class HandoffChunkAssembler {
206
239
  }
207
240
 
208
241
  private reassemble(total: number): string {
209
- const ordered: Buffer[] = [];
242
+ const ordered: Uint8Array[] = [];
210
243
  for (let i = 0; i < total; i += 1) {
211
244
  const part = this.chunks.get(i);
212
245
  if (part === undefined) {
@@ -217,7 +250,14 @@ export class HandoffChunkAssembler {
217
250
  }
218
251
  ordered.push(part);
219
252
  }
220
- return Buffer.concat(ordered).toString('utf8');
253
+ const joined = new Uint8Array(ordered.reduce((size, part) => size + part.length, 0));
254
+ let offset = 0;
255
+ for (const part of ordered) {
256
+ joined.set(part, offset);
257
+ offset += part.length;
258
+ }
259
+ // Keep a leading byte-order mark: it is part of the payload, as it was with `Buffer`.
260
+ return new TextDecoder('utf-8', { ignoreBOM: true }).decode(joined);
221
261
  }
222
262
  }
223
263
 
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ export { isObserverMessageType } from './observer-messages.js';
57
57
  export type { TProtocolSessionEventClassification } from './session-events.js';
58
58
  export type { IProtocolSession } from './protocol-session.js';
59
59
  export type {
60
+ IWireAgentDefinitionSummary,
60
61
  IWireHistoryEntry,
61
62
  TClientMessage,
62
63
  TServerMessage,
@@ -88,6 +89,9 @@ export type {
88
89
  TResumeSink,
89
90
  IAttachOptions,
90
91
  } from './session-resume-bridge.js';
92
+ // #3282 §4a: the Settings screen's host-owned read/write seam (parallels `IUsageQueryReporters`).
93
+ export { handleSettingsMessage, isSettingsMessage } from './settings-messages.js';
94
+ export type { ISettingsReporter, TSettingsUpdateOutcome } from './settings-messages.js';
91
95
 
92
96
  // SEC-008 admission is NOT here: `src/node/admission.ts` mints and compares transport tokens with
93
97
  // `node:crypto`. A browser never mints one — it presents one it was handed — so the exclusion costs
@@ -14,7 +14,10 @@
14
14
  * owner and is tracked there, not duplicated in a transport package.
15
15
  */
16
16
 
17
- import { SESSION_CHANGE_REFUSAL_CODES } from '@robota-sdk/agent-interface-session';
17
+ import {
18
+ SESSION_CHANGE_REFUSAL_CODES,
19
+ SESSION_DELETE_REFUSAL_CODES,
20
+ } from '@robota-sdk/agent-interface-session';
18
21
 
19
22
  import type { TBackgroundControlAction, TClientMessage, TServerMessage } from './wire-messages.js';
20
23
 
@@ -58,6 +61,11 @@ const isWireHistoryEntry = (v: unknown): boolean =>
58
61
  isString(v['type']);
59
62
  const isWireHistoryEntries = (v: unknown): boolean =>
60
63
  Array.isArray(v) && v.every(isWireHistoryEntry);
64
+ /** #3282 §4: one row of `agent_definitions` — name, one-line description, plain-words location. */
65
+ const isWireAgentDefinition = (v: unknown): boolean =>
66
+ isRecord(v) && isString(v['name']) && isString(v['description']) && isString(v['definedIn']);
67
+ const isWireAgentDefinitions = (v: unknown): boolean =>
68
+ Array.isArray(v) && v.every(isWireAgentDefinition);
61
69
 
62
70
  type TFieldCheck = (value: unknown) => boolean;
63
71
  type TVariantShape = Readonly<Record<string, TFieldCheck>>;
@@ -96,6 +104,38 @@ const isActionResponse: TFieldCheck = (v) =>
96
104
  isOptional(isString)(v['text'])) ||
97
105
  (v['type'] === 'cancelled' && v['values'] === undefined && v['text'] === undefined));
98
106
 
107
+ type TSettingsPatch = Extract<TClientMessage, { type: 'update-settings' }>['patch'];
108
+ /** Keyed by `field`, so a patch variant added to the union without a shape here fails to compile. */
109
+ const SETTINGS_PATCH_SHAPES: Readonly<Record<TSettingsPatch['field'], TVariantShape>> = {
110
+ language: { language: isNonEmptyString },
111
+ outputStyle: { styleId: isNonEmptyString },
112
+ preset: { presetId: isNonEmptyString },
113
+ permissionMode: { mode: isNonEmptyString },
114
+ sandbox: { enabled: isBoolean },
115
+ removePermissionRule: {
116
+ scope: isNonEmptyString,
117
+ kind: oneOf(['allow', 'deny', 'ask']),
118
+ pattern: isNonEmptyString,
119
+ },
120
+ mcpServerEnabled: { serverId: isNonEmptyString, enabled: isBoolean },
121
+ reloadMcpServers: {},
122
+ pluginEnabled: { pluginId: isNonEmptyString, enabled: isBoolean },
123
+ reloadPlugins: {},
124
+ installPlugin: { pluginId: isNonEmptyString },
125
+ uninstallPlugin: { pluginId: isNonEmptyString },
126
+ // #3282 §4b: "Providers & Models" — Use, Model, Delete.
127
+ providerProfile: { profileName: isNonEmptyString },
128
+ providerModel: { profileName: isNonEmptyString, modelId: isNonEmptyString },
129
+ deleteProviderProfile: { profileName: isNonEmptyString },
130
+ };
131
+ const isSettingsPatch: TFieldCheck = (v) => {
132
+ if (!isRecord(v) || !isString(v['field'])) return false;
133
+ const field = v['field'];
134
+ if (!Object.prototype.hasOwnProperty.call(SETTINGS_PATCH_SHAPES, field)) return false;
135
+ const shape = SETTINGS_PATCH_SHAPES[field as TSettingsPatch['field']];
136
+ return Object.entries(shape).every(([key, check]) => check(v[key]));
137
+ };
138
+
99
139
  type TWaitingLoopStopOutcome = Extract<TServerMessage, { type: 'waiting_loop_stop' }>['outcome'];
100
140
  /** Keyed by `kind`, so an outcome added to the union without a shape here fails to compile. */
101
141
  const WAITING_LOOP_STOP_OUTCOME_SHAPES: Readonly<
@@ -114,6 +154,72 @@ const isWaitingLoopStopOutcome: TFieldCheck = (v) => {
114
154
  return Object.entries(shape).every(([field, check]) => check(v[field]));
115
155
  };
116
156
 
157
+ /** Decode a `kind`-discriminated result union (the `TWaitingLoopStopOutcome` convention) from a shape
158
+ * map keyed by `kind`. */
159
+ function isKindDiscriminated(shapes: Readonly<Record<string, TVariantShape>>): TFieldCheck {
160
+ return (v) => {
161
+ if (!isRecord(v) || !isString(v['kind'])) return false;
162
+ const shape = Object.prototype.hasOwnProperty.call(shapes, v['kind']) ? shapes[v['kind']] : undefined;
163
+ if (shape === undefined) return false;
164
+ return Object.entries(shape).every(([field, check]) => check(v[field]));
165
+ };
166
+ }
167
+
168
+ // #3282 §4c: the Project panel's reads. Each result is the session method's own return value, crossing
169
+ // the wire unchanged (the `TWaitingLoopStopOutcome` convention above) — decoded from a `kind`-keyed
170
+ // shape map the same way.
171
+ const PROJECT_FILE_STATUSES = [
172
+ 'Added',
173
+ 'Modified',
174
+ 'Deleted',
175
+ 'Renamed',
176
+ 'Copied',
177
+ 'Untracked',
178
+ 'Conflicted',
179
+ ] as const;
180
+ const isProjectStatusFile: TFieldCheck = (v) =>
181
+ isRecord(v) &&
182
+ isNonEmptyString(v['path']) &&
183
+ oneOf(PROJECT_FILE_STATUSES)(v['status']) &&
184
+ isOptional(isNonEmptyString)(v['renamedFrom']) &&
185
+ isOptional(isFiniteNumber)(v['added']) &&
186
+ isOptional(isFiniteNumber)(v['removed']);
187
+ const isProjectStatusFiles: TFieldCheck = (v) => Array.isArray(v) && v.every(isProjectStatusFile);
188
+
189
+ type TProjectStatusReadResult = Extract<TServerMessage, { type: 'project_status' }>['result'];
190
+ const PROJECT_STATUS_READ_SHAPES: Readonly<Record<TProjectStatusReadResult['kind'], TVariantShape>> = {
191
+ status: {
192
+ branch: isOptional(isString),
193
+ unborn: isBoolean,
194
+ files: isProjectStatusFiles,
195
+ truncated: isBoolean,
196
+ },
197
+ 'not-a-repository': {},
198
+ failed: { message: isString },
199
+ };
200
+ const isProjectStatusRead = isKindDiscriminated(PROJECT_STATUS_READ_SHAPES);
201
+
202
+ const DIFF_LINE_TYPES = ['add', 'remove', 'context', 'hunk'] as const;
203
+ const isDiffLine: TFieldCheck = (v) =>
204
+ isRecord(v) && oneOf(DIFF_LINE_TYPES)(v['type']) && isString(v['text']) && isFiniteNumber(v['lineNumber']);
205
+ const isDiffLines: TFieldCheck = (v) => Array.isArray(v) && v.every(isDiffLine);
206
+
207
+ type TProjectDiffReadResult = Extract<TServerMessage, { type: 'project_diff' }>['result'];
208
+ const PROJECT_DIFF_READ_SHAPES: Readonly<Record<TProjectDiffReadResult['kind'], TVariantShape>> = {
209
+ diff: { diffLines: isDiffLines, truncated: isBoolean },
210
+ 'not-a-repository': {},
211
+ 'outside-workspace': {},
212
+ failed: { message: isString },
213
+ };
214
+ const isProjectDiffRead = isKindDiscriminated(PROJECT_DIFF_READ_SHAPES);
215
+
216
+ type TProjectMemoryReadResult = Extract<TServerMessage, { type: 'project_memory' }>['result'];
217
+ const PROJECT_MEMORY_READ_SHAPES: Readonly<Record<TProjectMemoryReadResult['kind'], TVariantShape>> = {
218
+ memory: { content: isString, path: isNonEmptyString, truncated: isBoolean },
219
+ unavailable: { message: isString },
220
+ };
221
+ const isProjectMemoryRead = isKindDiscriminated(PROJECT_MEMORY_READ_SHAPES);
222
+
117
223
  /** One entry per `TClientMessage` variant — a variant added to the union without one fails the test. */
118
224
  export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVariantShape>> = {
119
225
  submit: { prompt: isNonEmptyString },
@@ -130,9 +236,17 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
130
236
  'get-context': {},
131
237
  'get-commands': {},
132
238
  'get-status': {},
239
+ 'list-models': { requestId: isNonEmptyString },
240
+ 'get-agent-definitions': { requestId: isNonEmptyString },
133
241
  'list-sessions': { requestId: isNonEmptyString },
134
242
  'new-session': { requestId: isOptional(isNonEmptyString) },
135
243
  'switch-session': { sessionId: isNonEmptyString, requestId: isOptional(isNonEmptyString) },
244
+ 'rename-session': {
245
+ sessionId: isNonEmptyString,
246
+ name: isNonEmptyString,
247
+ requestId: isNonEmptyString,
248
+ },
249
+ 'delete-session': { sessionId: isNonEmptyString, requestId: isNonEmptyString },
136
250
  'get-usage-report': {},
137
251
  'get-personal-usage-report': {
138
252
  requestId: isNonEmptyString,
@@ -152,6 +266,9 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
152
266
  cursor: isOptional(isExecutionDetailCursor),
153
267
  },
154
268
  'stop-waiting-loop': { requestId: isNonEmptyString },
269
+ 'project-status': { requestId: isNonEmptyString },
270
+ 'project-diff': { requestId: isNonEmptyString, path: isNonEmptyString },
271
+ 'project-memory': { requestId: isNonEmptyString },
155
272
  'get-background-tasks': { filter: isOptional(isBackgroundTaskListFilter) },
156
273
  'get-background-task': { taskId: isNonEmptyString },
157
274
  'get-background-job-groups': {},
@@ -168,6 +285,8 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
168
285
  'ask-response': { id: isNonEmptyString, response: isActionResponse },
169
286
  resume: { lastSeq: isFiniteNumber },
170
287
  ack: { seq: isFiniteNumber },
288
+ 'get-settings': { requestId: isNonEmptyString },
289
+ 'update-settings': { requestId: isNonEmptyString, patch: isSettingsPatch },
171
290
  };
172
291
 
173
292
  const authored: TVariantShape = { driverId: isOptional(isString) };
@@ -181,20 +300,38 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
181
300
  thinking: { isThinking: isBoolean, ...authored },
182
301
  complete: { result: isRecord, ...authored },
183
302
  interrupted: { result: isRecord, ...authored },
184
- error: { message: isString, ...authored },
303
+ error: {
304
+ message: isString,
305
+ code: isOptional(oneOf(['auth', 'rate_limit', 'model_unavailable', 'network', 'provider'])),
306
+ provider: isOptional(isString),
307
+ retryAfterSeconds: isOptional(isFiniteNumber),
308
+ model: isOptional(isString),
309
+ ...authored,
310
+ },
185
311
  command_result: {
186
312
  name: isString,
187
313
  message: isString,
188
314
  success: isBoolean,
189
315
  requestId: isOptional(isString),
190
316
  },
191
- messages: { messages: isRecordArray },
317
+ messages: { messages: isRecordArray, display: isOptional(isRecordArray), ...authored },
192
318
  history: { startIndex: isCount, total: isCount, entries: isWireHistoryEntries },
193
319
  context: { state: isRecord },
194
320
  history_changed: {},
195
321
  turn_source: { source: oneOf(Object.keys(TURN_SOURCES)) },
196
322
  commands: { commands: isRecordArray, skills: isRecordArray },
197
323
  session_status: { status: isRecord },
324
+ model_list: {
325
+ requestId: isNonEmptyString,
326
+ groups: isRecordArray,
327
+ currentProfile: isOptional(isString),
328
+ currentModel: isString,
329
+ },
330
+ agent_definitions: {
331
+ requestId: isNonEmptyString,
332
+ agents: isWireAgentDefinitions,
333
+ current: isNonEmptyString,
334
+ },
198
335
  sessions: { requestId: isNonEmptyString, listing: isRecord },
199
336
  sessions_error: {
200
337
  requestId: isNonEmptyString,
@@ -207,6 +344,14 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
207
344
  message: isString,
208
345
  requestId: isOptional(isString),
209
346
  },
347
+ session_renamed_in_list: { requestId: isNonEmptyString, sessionId: isString, name: isString },
348
+ session_rename_failed: { requestId: isNonEmptyString, message: isString },
349
+ session_deleted: { requestId: isNonEmptyString, sessionId: isString },
350
+ session_delete_failed: {
351
+ requestId: isNonEmptyString,
352
+ code: oneOf(SESSION_DELETE_REFUSAL_CODES),
353
+ message: isString,
354
+ },
210
355
  usage_report: { report: isRecord },
211
356
  personal_usage_report: { requestId: isNonEmptyString, report: isRecord },
212
357
  personal_usage_report_error: {
@@ -232,6 +377,9 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
232
377
  execution_detail: { requestId: isNonEmptyString, page: isRecord },
233
378
  execution_detail_error: { requestId: isNonEmptyString, message: isString },
234
379
  waiting_loop_stop: { requestId: isNonEmptyString, outcome: isWaitingLoopStopOutcome },
380
+ project_status: { requestId: isNonEmptyString, result: isProjectStatusRead },
381
+ project_diff: { requestId: isNonEmptyString, result: isProjectDiffRead },
382
+ project_memory: { requestId: isNonEmptyString, result: isProjectMemoryRead },
235
383
  background_task_event: { event: isRecord },
236
384
  background_job_group_event: { event: isRecord },
237
385
  plan_event: { event: isRecord },
@@ -256,6 +404,12 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
256
404
  },
257
405
  protocol_error: { message: isString, requestId: isOptional(isString) },
258
406
  resume_gap: {},
407
+ settings: { requestId: isNonEmptyString, settings: isRecord },
408
+ settings_error: {
409
+ requestId: isNonEmptyString,
410
+ code: oneOf(['not_available', 'invalid', 'refused', 'update_failed']),
411
+ message: isString,
412
+ },
259
413
  };
260
414
 
261
415
  function decodeVariant<TMessage extends { type: string }>(
@@ -16,10 +16,12 @@ export const OBSERVER_MESSAGES: ReadonlySet<TClientMessage['type']> = new Set<
16
16
  'get-context',
17
17
  'get-status',
18
18
  'get-commands',
19
+ 'list-models',
19
20
  'get-executing',
20
21
  'get-pending',
21
22
  'get-execution-workspace',
22
23
  'read-execution-detail',
24
+ 'get-agent-definitions',
23
25
  'get-usage-report',
24
26
  'list-sessions',
25
27
  'get-background-tasks',
@@ -28,6 +30,15 @@ export const OBSERVER_MESSAGES: ReadonlySet<TClientMessage['type']> = new Set<
28
30
  'get-background-job-group',
29
31
  'wait-background-job-group',
30
32
  'read-background-task-log',
33
+ // #3282 §4a: reading the Settings snapshot is a pure read of this session's own configuration.
34
+ // `update-settings` stays out — an observer never writes.
35
+ 'get-settings',
36
+ // #3282 §4c: the Project panel's reads — git status, one file's diff, project memory. All three are
37
+ // read-only (there is no "restore"/write counterpart in this feature — see the SPEC.md design
38
+ // decision on why checkpoints/rewind are excluded entirely), so all three are observer-safe.
39
+ 'project-status',
40
+ 'project-diff',
41
+ 'project-memory',
31
42
  ]);
32
43
 
33
44
  /** Whether an observer may send a message of this type. */
@@ -0,0 +1,74 @@
1
+ /**
2
+ * #3282 §4c — the Project panel's reads (git status, one file's diff, project memory).
3
+ *
4
+ * `TProjectReadCapableSession` is `IProtocolSession & Partial<ISessionProjectRead>` (see
5
+ * `protocol-session.ts`'s doc comment): the three methods are runtime-probed, never assumed, so
6
+ * adding this capability changes nothing for a session or test double that does not have it — a
7
+ * request against one answers `protocol_error` (host-neutral, not a made-up "kind"), the same way an
8
+ * unknown message type already does.
9
+ */
10
+ import type { TOutboundDeliver } from './outbound-delivery.js';
11
+ import type { TProjectReadCapableSession } from './protocol-session.js';
12
+ import type { TClientMessage } from './wire-messages.js';
13
+
14
+ type TProjectReadMessage = Extract<
15
+ TClientMessage,
16
+ { type: 'project-status' | 'project-diff' | 'project-memory' }
17
+ >;
18
+
19
+ export function isProjectReadMessage(msg: TClientMessage): msg is TProjectReadMessage {
20
+ return msg.type === 'project-status' || msg.type === 'project-diff' || msg.type === 'project-memory';
21
+ }
22
+
23
+ function notSupported(deliver: TOutboundDeliver, requestId: string): void {
24
+ deliver({
25
+ type: 'protocol_error',
26
+ message: 'This host does not support the Project panel.',
27
+ requestId,
28
+ });
29
+ }
30
+
31
+ export function handleProjectReadMessage(
32
+ session: TProjectReadCapableSession,
33
+ deliver: TOutboundDeliver,
34
+ msg: TProjectReadMessage,
35
+ ): void {
36
+ const { requestId } = msg;
37
+ if (msg.type === 'project-status') {
38
+ if (!session.readProjectStatus) return notSupported(deliver, requestId);
39
+ session.readProjectStatus().then(
40
+ (result) => deliver({ type: 'project_status', requestId, result }),
41
+ (error: unknown) =>
42
+ deliver({
43
+ type: 'project_status',
44
+ requestId,
45
+ result: { kind: 'failed', message: error instanceof Error ? error.message : String(error) },
46
+ }),
47
+ );
48
+ } else if (msg.type === 'project-diff') {
49
+ if (!session.readProjectDiff) return notSupported(deliver, requestId);
50
+ session.readProjectDiff(msg.path).then(
51
+ (result) => deliver({ type: 'project_diff', requestId, result }),
52
+ (error: unknown) =>
53
+ deliver({
54
+ type: 'project_diff',
55
+ requestId,
56
+ result: { kind: 'failed', message: error instanceof Error ? error.message : String(error) },
57
+ }),
58
+ );
59
+ } else {
60
+ if (!session.readProjectMemory) return notSupported(deliver, requestId);
61
+ session.readProjectMemory().then(
62
+ (result) => deliver({ type: 'project_memory', requestId, result }),
63
+ (error: unknown) =>
64
+ deliver({
65
+ type: 'project_memory',
66
+ requestId,
67
+ result: {
68
+ kind: 'unavailable',
69
+ message: error instanceof Error ? error.message : String(error),
70
+ },
71
+ }),
72
+ );
73
+ }
74
+ }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ISessionAgentJobs,
2
3
  ISessionBackgroundGroups,
3
4
  ISessionBackgroundTasks,
4
5
  ISessionCommands,
@@ -8,6 +9,7 @@ import type {
8
9
  ISessionExecutionDetail,
9
10
  ISessionExecutionState,
10
11
  ISessionExecutionWorkspace,
12
+ ISessionProjectRead,
11
13
  ISessionPromptResolution,
12
14
  ISessionSelfPacedLoopControl,
13
15
  ISessionStatusRead,
@@ -31,4 +33,18 @@ export interface IProtocolSession
31
33
  ISessionBackgroundGroups,
32
34
  ISessionExecutionWorkspace,
33
35
  ISessionExecutionDetail,
34
- ISessionSelfPacedLoopControl {}
36
+ ISessionSelfPacedLoopControl,
37
+ // #3282 §4: the agent switcher's read (`listAgentDefinitions`/`getDefaultAgentType`) — the
38
+ // group's write members (`spawnAgentJob` and friends) stay unused by any wire handler; they run
39
+ // only through `executeCommand`, same as every other command-only write.
40
+ ISessionAgentJobs {}
41
+
42
+ /**
43
+ * #3282 §4c: a protocol session that ALSO answers the Project panel's reads. `Partial` (not a fourth
44
+ * mandatory role on {@link IProtocolSession} itself) deliberately: every existing test double and
45
+ * every other host that builds an `IProtocolSession` keeps type-checking unchanged, and the panel's
46
+ * three methods are runtime-probed (`typeof session.readProjectStatus === 'function'`) rather than
47
+ * assumed — `InteractiveSession` (the one production implementation) provides all three, satisfying
48
+ * this type with no further wiring.
49
+ */
50
+ export type TProjectReadCapableSession = IProtocolSession & Partial<ISessionProjectRead>;
@@ -1,4 +1,4 @@
1
- import { isSessionChangeRefusal } from '@robota-sdk/agent-interface-session';
1
+ import { isSessionChangeRefusal, isSessionDeleteRefusal } from '@robota-sdk/agent-interface-session';
2
2
 
3
3
  import type { TOutboundDeliver } from './outbound-delivery.js';
4
4
  import type { TClientMessage } from './wire-messages.js';
@@ -15,6 +15,17 @@ export function isSessionDirectoryMessage(msg: TClientMessage): msg is TSessionD
15
15
  );
16
16
  }
17
17
 
18
+ type TSessionRenameMessage = Extract<TClientMessage, { type: 'rename-session' }>;
19
+ type TSessionDeleteMessage = Extract<TClientMessage, { type: 'delete-session' }>;
20
+
21
+ export function isSessionRenameMessage(msg: TClientMessage): msg is TSessionRenameMessage {
22
+ return msg.type === 'rename-session';
23
+ }
24
+
25
+ export function isSessionDeleteMessage(msg: TClientMessage): msg is TSessionDeleteMessage {
26
+ return msg.type === 'delete-session';
27
+ }
28
+
18
29
  /**
19
30
  * #3189: the host's sessions. Listing answers on its own correlated reply. A new session or a switch
20
31
  * answers nothing on success: `session_switched` from the connection's own session is the signal. A
@@ -83,3 +94,69 @@ function listSessions(
83
94
  function failureMessage(error: Error | string): string {
84
95
  return error instanceof Error ? error.message : String(error);
85
96
  }
97
+
98
+ /**
99
+ * #3289 §1: rename a session from the list — current or not. Success replies with the new name so
100
+ * the client can update it optimistically; the client also re-lists to pick up everything else a
101
+ * rename can imply (nothing today, but the reply shape matches `delete-session`'s on purpose).
102
+ */
103
+ export function handleSessionRenameMessage(
104
+ deliver: TOutboundDeliver,
105
+ msg: TSessionRenameMessage,
106
+ directory: ISessionDirectory | undefined,
107
+ ): void {
108
+ if (!directory) {
109
+ deliver({
110
+ type: 'session_rename_failed',
111
+ requestId: msg.requestId,
112
+ message: 'Sessions cannot be renamed on this host.',
113
+ });
114
+ return;
115
+ }
116
+ directory.renameSession(msg.sessionId, msg.name).then(
117
+ () =>
118
+ deliver({
119
+ type: 'session_renamed_in_list',
120
+ requestId: msg.requestId,
121
+ sessionId: msg.sessionId,
122
+ name: msg.name,
123
+ }),
124
+ (error: Error | string) =>
125
+ deliver({
126
+ type: 'session_rename_failed',
127
+ requestId: msg.requestId,
128
+ message: failureMessage(error),
129
+ }),
130
+ );
131
+ }
132
+
133
+ /**
134
+ * #3289 §1: remove a stored session's record. Refused when it is live on another client or running a
135
+ * turn; deleting the current session switches this binding away first (the directory's own job), so
136
+ * by the time this answers, the caller's own session is never the one just removed.
137
+ */
138
+ export function handleSessionDeleteMessage(
139
+ deliver: TOutboundDeliver,
140
+ msg: TSessionDeleteMessage,
141
+ directory: ISessionDirectory | undefined,
142
+ ): void {
143
+ if (!directory) {
144
+ deliver({
145
+ type: 'session_delete_failed',
146
+ requestId: msg.requestId,
147
+ code: 'not_available',
148
+ message: 'Sessions cannot be deleted on this host.',
149
+ });
150
+ return;
151
+ }
152
+ directory.deleteSession(msg.sessionId).then(
153
+ () => deliver({ type: 'session_deleted', requestId: msg.requestId, sessionId: msg.sessionId }),
154
+ (error: Error | string) =>
155
+ deliver({
156
+ type: 'session_delete_failed',
157
+ requestId: msg.requestId,
158
+ code: isSessionDeleteRefusal(error) ? error.code : 'failed',
159
+ message: failureMessage(error),
160
+ }),
161
+ );
162
+ }