@robota-sdk/agent-transport 3.0.0-beta.78 → 3.0.0-beta.81

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 (111) hide show
  1. package/README.md +84 -26
  2. package/dist/browser/client.d.ts +2 -0
  3. package/dist/browser/client.js +1 -0
  4. package/dist/browser/index.d.ts +520 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +2 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/browser/message-decoders-BB8n-H2X.d.ts +252 -0
  9. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
  10. package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
  11. package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
  12. package/dist/node/index.cjs +1 -1
  13. package/dist/node/index.d.cts +766 -0
  14. package/dist/node/index.d.cts.map +1 -0
  15. package/dist/node/index.d.ts +765 -27
  16. package/dist/node/index.d.ts.map +1 -1
  17. package/dist/node/index.js +1 -1
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/node.cjs +1 -0
  20. package/dist/node/node.d.cts +109 -0
  21. package/dist/node/node.d.cts.map +1 -0
  22. package/dist/node/node.d.ts +109 -0
  23. package/dist/node/node.d.ts.map +1 -0
  24. package/dist/node/node.js +2 -0
  25. package/dist/node/node.js.map +1 -0
  26. package/package.json +68 -47
  27. package/src/__tests__/access-token-verifier.test.ts +672 -0
  28. package/src/__tests__/admission.test.ts +130 -0
  29. package/src/__tests__/channel-frames.test.ts +214 -0
  30. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  31. package/src/__tests__/handoff-chunking.test.ts +215 -0
  32. package/src/__tests__/handoff-manifest.test.ts +52 -0
  33. package/src/__tests__/message-decoders.test.ts +249 -0
  34. package/src/__tests__/outbound-delivery.test.ts +558 -0
  35. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  36. package/src/__tests__/personal-usage-report.test.ts +193 -0
  37. package/src/__tests__/resume-buffer.test.ts +93 -0
  38. package/src/__tests__/session-event-delivery.test.ts +101 -0
  39. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  40. package/src/__tests__/session-message-handler.test.ts +590 -0
  41. package/src/__tests__/session-resume-bridge.test.ts +406 -0
  42. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  43. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  44. package/src/__tests__/ws-broadcast-events.test.ts +84 -0
  45. package/src/background-messages.ts +169 -0
  46. package/src/channel-frames.ts +182 -0
  47. package/src/client.ts +9 -0
  48. package/src/handoff-chunking.ts +235 -0
  49. package/src/index.ts +112 -3
  50. package/src/message-decoders.ts +229 -0
  51. package/src/message-parser.ts +33 -0
  52. package/src/node/access-token-verifier.ts +363 -0
  53. package/src/node/admission.ts +153 -0
  54. package/src/node/handoff-manifest.ts +77 -0
  55. package/src/node/index.ts +10 -0
  56. package/src/outbound-delivery.ts +231 -0
  57. package/src/peer-message-ledger.ts +193 -0
  58. package/src/protocol-session.ts +28 -0
  59. package/src/resume-buffer.ts +123 -0
  60. package/src/session-events.ts +198 -0
  61. package/src/session-message-handler.ts +282 -0
  62. package/src/session-query-messages.ts +42 -0
  63. package/src/session-resume-bridge.ts +252 -0
  64. package/src/usage-messages.ts +195 -0
  65. package/src/wire-messages.ts +165 -0
  66. package/dist/node/headless/index.cjs +0 -1
  67. package/dist/node/headless/index.d.ts +0 -2
  68. package/dist/node/headless/index.js +0 -1
  69. package/dist/node/headless-DG_MEbf0.js +0 -19
  70. package/dist/node/headless-DG_MEbf0.js.map +0 -1
  71. package/dist/node/headless-xq77JBq0.cjs +0 -18
  72. package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
  73. package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
  74. package/dist/node/index-JIvzrtaA.d.ts +0 -52
  75. package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
  76. package/dist/node/index-JTD_xtEQ.d.ts +0 -96
  77. package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
  78. package/dist/node/index-y_HBOfzO.d.ts +0 -52
  79. package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
  80. package/dist/node/programmatic/index.cjs +0 -1
  81. package/dist/node/programmatic/index.d.ts +0 -2
  82. package/dist/node/programmatic/index.js +0 -1
  83. package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
  84. package/dist/node/programmatic-70dxIXtS.js +0 -2
  85. package/dist/node/programmatic-70dxIXtS.js.map +0 -1
  86. package/dist/node/testing/index.cjs +0 -1
  87. package/dist/node/testing/index.d.ts +0 -2
  88. package/dist/node/testing/index.js +0 -1
  89. package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
  90. package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
  91. package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
  92. package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
  93. package/src/headless/HeadlessInteractionChannel.ts +0 -129
  94. package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
  95. package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
  96. package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
  97. package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
  98. package/src/headless/__tests__/headless-runner.test.ts +0 -508
  99. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
  100. package/src/headless/__tests__/headless-transport.test.ts +0 -267
  101. package/src/headless/cli-input.ts +0 -50
  102. package/src/headless/headless-runner.ts +0 -230
  103. package/src/headless/headless-stream-json.ts +0 -148
  104. package/src/headless/headless-transport.ts +0 -43
  105. package/src/headless/index.ts +0 -8
  106. package/src/headless/print-terminal.ts +0 -57
  107. package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
  108. package/src/programmatic/createProgrammaticAgent.ts +0 -71
  109. package/src/programmatic/index.ts +0 -5
  110. package/src/testing/index.ts +0 -10
  111. package/src/transport-registry.ts +0 -103
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Outbound session-event subscription for the WS/WebRTC transports (split from `session-message-handler.ts` —
3
+ * the inbound message routing stays there; this module owns the session→`TServerMessage` fan-out).
4
+ *
5
+ * Used by `createSessionMessageHandler` (one subscription per channel) and by the persistent
6
+ * `SessionResumeBridge` (REMOTE-013 E4 — a SINGLE subscription that outlives per-channel handlers).
7
+ */
8
+
9
+ import type { TOutboundDeliver } from './outbound-delivery.js';
10
+ import type { IProtocolSession } from './protocol-session.js';
11
+ import type {
12
+ IExecutionWorkspaceEvent,
13
+ TBackgroundJobGroupEvent,
14
+ TBackgroundTaskEvent,
15
+ } from '@robota-sdk/agent-interface-execution';
16
+ import type { TDriverId, TInteractiveEventName } from '@robota-sdk/agent-interface-session';
17
+ import type {
18
+ IAskRequestEvent,
19
+ IBranchEvent,
20
+ IContextFileRefreshedEvent,
21
+ IExecutionResult,
22
+ IPermissionRequestEvent,
23
+ IPromptResolvedEvent,
24
+ IPlanApprovalEvent,
25
+ ISessionRenamedEvent,
26
+ IToolState,
27
+ IUiIntentEvent,
28
+ } from '@robota-sdk/agent-interface-session';
29
+
30
+ export type TProtocolSessionEventClassification = 'forwarded' | 'requester-routed' | 'non-surface';
31
+
32
+ /** Exhaustive protocol-owner policy over the shared session-event vocabulary. */
33
+ export const PROTOCOL_SESSION_EVENT_CLASSIFICATION = {
34
+ text_delta: 'forwarded',
35
+ tool_start: 'forwarded',
36
+ tool_end: 'forwarded',
37
+ thinking: 'forwarded',
38
+ complete: 'forwarded',
39
+ error: 'forwarded',
40
+ context_update: 'non-surface',
41
+ compact: 'non-surface',
42
+ interrupted: 'forwarded',
43
+ skill_activation: 'non-surface',
44
+ background_task_event: 'forwarded',
45
+ background_job_group_event: 'forwarded',
46
+ execution_workspace_event: 'forwarded',
47
+ user_message: 'forwarded',
48
+ turn_source: 'non-surface',
49
+ context_file_refreshed: 'forwarded',
50
+ memory_event: 'non-surface',
51
+ goal_event: 'non-surface',
52
+ plan_event: 'forwarded',
53
+ branch_event: 'forwarded',
54
+ permission_request: 'forwarded',
55
+ ask_request: 'forwarded',
56
+ prompt_resolved: 'forwarded',
57
+ ui_intent: 'requester-routed',
58
+ session_renamed: 'forwarded',
59
+ history_cleared: 'forwarded',
60
+ } as const satisfies Record<TInteractiveEventName, TProtocolSessionEventClassification>;
61
+
62
+ /**
63
+ * Options for {@link subscribeSessionEvents}.
64
+ *
65
+ * ARCH-030: `onDeliveryError` is NOT here. Carrier-failure containment moved to the one
66
+ * connection-scoped {@link TOutboundDeliver} boundary this function now receives, so the fan-out and
67
+ * every reply share a single guard instead of the fan-out having its own.
68
+ */
69
+ export interface ISubscribeSessionEventsOptions {
70
+ /**
71
+ * CMD-004 Stage D: lazily read the SERVER-ASSIGNED driver id of THIS surface — `ui_intent` is
72
+ * requester-routed against it (lazy because the resume bridge binds the id only after pairing).
73
+ */
74
+ getSurfaceDriverId?: () => TDriverId | undefined;
75
+ }
76
+
77
+ /**
78
+ * Subscribe the session's events and forward each as a `TServerMessage` through the connection's
79
+ * outbound boundary; returns an unsubscribe. Exported (REMOTE-013 E4) so the persistent
80
+ * `SessionResumeBridge` can own a SINGLE subscription that outlives per-channel handlers.
81
+ *
82
+ * ARCH-030: the second parameter is the boundary, not a raw sink — a carrier `send` cannot be passed
83
+ * here, which is what keeps the fan-out and the reply paths on one guard.
84
+ */
85
+ export function subscribeSessionEvents(
86
+ session: IProtocolSession,
87
+ deliver: TOutboundDeliver,
88
+ options: ISubscribeSessionEventsOptions = {},
89
+ ): () => void {
90
+ // ARCH-030: no local guard wrapper any more, and no event name passed alongside the message. The name
91
+ // 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.
94
+ // REMOTE-014 E5: stamp the ACTIVE turn's driver id onto TURN-AUTHORED events (co-drive authorship,
95
+ // display-only), read at emit time. Only these events — background/goal/memory/execution-workspace events
96
+ // are NOT authored by a driver turn and carry no `driverId`. `undefined` when idle or unattributed.
97
+ const attr = (): { driverId?: TDriverId } => {
98
+ // ARCH-012: a plain call, not `?.() ?? undefined`. The member is required, so `null` means
99
+ // nobody is driving — it can no longer also mean "this host cannot answer".
100
+ const driverId = session.getActiveDriverId();
101
+ return driverId ? { driverId } : {};
102
+ };
103
+ const onUserMessage = (content: string): void =>
104
+ deliver({ type: 'user_message', content, ...attr() });
105
+ const onTextDelta = (delta: string): void => deliver({ type: 'text_delta', delta, ...attr() });
106
+ const onToolStart = (state: IToolState): void =>
107
+ deliver({ type: 'tool_start', state, ...attr() });
108
+ const onToolEnd = (state: IToolState): void => deliver({ type: 'tool_end', state, ...attr() });
109
+ const onThinking = (isThinking: boolean): void =>
110
+ deliver({ type: 'thinking', isThinking, ...attr() });
111
+ const onComplete = (result: IExecutionResult): void =>
112
+ deliver({ type: 'complete', result, ...attr() });
113
+ const onInterrupted = (result: IExecutionResult): void =>
114
+ deliver({ type: 'interrupted', result, ...attr() });
115
+ const onError = (error: Error): void =>
116
+ deliver({ type: 'error', message: error.message, ...attr() });
117
+ const onBackgroundTaskEvent = (event: TBackgroundTaskEvent): void =>
118
+ deliver({ type: 'background_task_event', event });
119
+ const onBackgroundJobGroupEvent = (event: TBackgroundJobGroupEvent): void =>
120
+ deliver({ type: 'background_job_group_event', event });
121
+ const onExecutionWorkspace = (event: IExecutionWorkspaceEvent): void =>
122
+ deliver({
123
+ type: 'execution_workspace_event',
124
+ snapshot: event.snapshot,
125
+ });
126
+ const onPlanEvent = (event: IPlanApprovalEvent): void => deliver({ type: 'plan_event', event });
127
+ const onContextFileRefreshed = (event: IContextFileRefreshedEvent): void =>
128
+ deliver({ type: 'context_file_refreshed', event });
129
+ const onBranchEvent = (event: IBranchEvent): void => deliver({ type: 'branch_event', event });
130
+ // REMOTE-007: forward the transport-neutral prompt events so a remote surface can render + answer the
131
+ // 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 =>
136
+ deliver({ type: 'prompt_resolved', event });
137
+ // CMD-004 Stage D: `ui_intent` is REQUESTER-ROUTED — delivered only to the surface whose
138
+ // server-assigned driver id issued the command. An UNATTRIBUTED intent (no requester id, e.g. an
139
+ // idle model-invoked command) is unroutable and reaches every surface — never a silent drop.
140
+ const onUiIntent = (event: IUiIntentEvent): void => {
141
+ if (
142
+ event.requesterDriverId !== undefined &&
143
+ event.requesterDriverId !== options.getSurfaceDriverId?.()
144
+ ) {
145
+ return; // another surface's intent — this surface never sees it (and never buffers it)
146
+ }
147
+ deliver({ type: 'ui_intent', event });
148
+ };
149
+ // CMD-004 Stage E: BROADCAST session-state events — the host executed the rename/clear; EVERY
150
+ // attached surface (co-driving included) reflects it. Never requester-filtered, unlike `ui_intent`.
151
+ const onSessionRenamed = (event: ISessionRenamedEvent): void =>
152
+ deliver({ type: 'session_renamed', event });
153
+ const onHistoryCleared = (): void => deliver({ type: 'history_cleared' });
154
+
155
+ session.on('user_message', onUserMessage);
156
+ session.on('text_delta', onTextDelta);
157
+ session.on('tool_start', onToolStart);
158
+ session.on('tool_end', onToolEnd);
159
+ session.on('thinking', onThinking);
160
+ session.on('complete', onComplete);
161
+ session.on('interrupted', onInterrupted);
162
+ session.on('error', onError);
163
+ session.on('background_task_event', onBackgroundTaskEvent);
164
+ session.on('background_job_group_event', onBackgroundJobGroupEvent);
165
+ session.on('execution_workspace_event', onExecutionWorkspace);
166
+ session.on('plan_event', onPlanEvent);
167
+ session.on('context_file_refreshed', onContextFileRefreshed);
168
+ session.on('branch_event', onBranchEvent);
169
+ session.on('permission_request', onPermissionRequest);
170
+ session.on('ask_request', onAskRequest);
171
+ session.on('prompt_resolved', onPromptResolved);
172
+ session.on('ui_intent', onUiIntent);
173
+ session.on('session_renamed', onSessionRenamed);
174
+ session.on('history_cleared', onHistoryCleared);
175
+
176
+ return (): void => {
177
+ session.off('user_message', onUserMessage);
178
+ session.off('text_delta', onTextDelta);
179
+ session.off('tool_start', onToolStart);
180
+ session.off('tool_end', onToolEnd);
181
+ session.off('thinking', onThinking);
182
+ session.off('complete', onComplete);
183
+ session.off('interrupted', onInterrupted);
184
+ session.off('error', onError);
185
+ session.off('background_task_event', onBackgroundTaskEvent);
186
+ session.off('background_job_group_event', onBackgroundJobGroupEvent);
187
+ session.off('execution_workspace_event', onExecutionWorkspace);
188
+ session.off('plan_event', onPlanEvent);
189
+ session.off('context_file_refreshed', onContextFileRefreshed);
190
+ session.off('branch_event', onBranchEvent);
191
+ session.off('permission_request', onPermissionRequest);
192
+ session.off('ask_request', onAskRequest);
193
+ session.off('prompt_resolved', onPromptResolved);
194
+ session.off('ui_intent', onUiIntent);
195
+ session.off('session_renamed', onSessionRenamed);
196
+ session.off('history_cleared', onHistoryCleared);
197
+ };
198
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Carrier-neutral session message adapter.
3
+ *
4
+ * Framework-agnostic: works with any transport implementation via
5
+ * send/onMessage callbacks. No dependency on a carrier library.
6
+ *
7
+ * Protocol: JSON messages with { type, ...payload } structure.
8
+ * Server pushes IProtocolSession events to client in real-time.
9
+ */
10
+
11
+ import {
12
+ handleBackgroundControlMessage,
13
+ handleBackgroundQueryMessage,
14
+ } from './background-messages.js';
15
+ import { parseClientMessage } from './message-parser.js';
16
+ import { subscribeSessionEvents } from './session-events.js';
17
+ import { handleSessionQueryMessage, isSessionQueryMessage } from './session-query-messages.js';
18
+ import { handleUsageQueryMessage } from './usage-messages.js';
19
+
20
+ import type { TOutboundDeliver } from './outbound-delivery.js';
21
+ import type { IProtocolSession } from './protocol-session.js';
22
+ import type { IUsageQueryReporters } from './usage-messages.js';
23
+ import type { TClientMessage } from './wire-messages.js';
24
+ import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
25
+ import type { TDriverId } from '@robota-sdk/agent-interface-session';
26
+
27
+ // Outbound session→TServerMessage fan-out (incl. CMD-004 requester-routed `ui_intent`) lives in
28
+ // `session-events.ts`; re-exported here for the bridge and existing importers.
29
+ export { subscribeSessionEvents } from './session-events.js';
30
+ export type { ISubscribeSessionEventsOptions } from './session-events.js';
31
+ export { parseClientMessage } from './message-parser.js';
32
+
33
+ export interface ISessionMessageHandlerOptions {
34
+ /** IProtocolSession to expose. */
35
+ session: IProtocolSession;
36
+ /**
37
+ * ARCH-030: the CARRIER's connection-scoped outbound delivery boundary — not a raw `send`, and not a
38
+ * `send` plus an error callback for this handler to assemble into one. The carrier owns both the sink
39
+ * and the "what does a failed send mean for this connection" policy, so it builds the boundary and
40
+ * passes it down; the raw sink never crosses this parameter, which is what stops a future reply family
41
+ * from reaching the wire unguarded.
42
+ */
43
+ deliver: TOutboundDeliver;
44
+ /**
45
+ * REMOTE-014 E5: the SERVER-ASSIGNED driver id for THIS remote surface (the E3 `deviceId`). Injected into
46
+ * every inbound `submit`/`command`/prompt-response so a co-drive turn/answer is attributed to this driver —
47
+ * a client-supplied driver id is NEVER trusted. Absent → unattributed (the session defaults to the owner).
48
+ */
49
+ driverId?: TDriverId;
50
+ /** Trusted carrier-owned product surface, kept separate from driver identity. */
51
+ surface?: TUsageSurface;
52
+ /** Host-owned cross-session read model. The protocol only correlates and carries its result. */
53
+ personalUsageReporter?: NonNullable<IUsageQueryReporters['personalUsageReporter']>;
54
+ /** Host-owned current-session trace/cost producer for the pre-existing message family. */
55
+ usageReporter?: NonNullable<IUsageQueryReporters['usageReporter']>;
56
+ /** Host-owned stored-session producer used by cross-session drill-down. */
57
+ storedSessionUsageReporter?: NonNullable<IUsageQueryReporters['storedSessionUsageReporter']>;
58
+ }
59
+
60
+ /**
61
+ * Create a carrier-neutral message handler for an IProtocolSession.
62
+ *
63
+ * Returns:
64
+ * - `onMessage(data)`: call this when the WebSocket receives a message
65
+ * - `cleanup()`: call this when the WebSocket disconnects
66
+ *
67
+ * Usage:
68
+ * ```typescript
69
+ * const delivery = createOutboundDelivery(
70
+ * (msg) => ws.send(JSON.stringify(msg)),
71
+ * (error) => ws.close(1011, error.message),
72
+ * );
73
+ * const { onMessage, cleanup } = createSessionMessageHandler({ session: interactiveSession, deliver: delivery });
74
+ *
75
+ * ws.on('message', (data) => onMessage(String(data)));
76
+ * ws.on('close', cleanup);
77
+ * ```
78
+ */
79
+ export function createSessionMessageHandler(options: ISessionMessageHandlerOptions): {
80
+ onMessage: (data: string) => void;
81
+ cleanup: () => void;
82
+ } {
83
+ const cleanup = subscribeSessionEvents(options.session, options.deliver, {
84
+ getSurfaceDriverId: () => options.driverId,
85
+ });
86
+ const onMessage = createMessageHandler(
87
+ options.session,
88
+ options.deliver,
89
+ options.driverId,
90
+ {
91
+ personalUsageReporter: options.personalUsageReporter,
92
+ usageReporter: options.usageReporter,
93
+ storedSessionUsageReporter: options.storedSessionUsageReporter,
94
+ },
95
+ options.surface,
96
+ );
97
+
98
+ return { onMessage, cleanup };
99
+ }
100
+
101
+ function createMessageHandler(
102
+ session: IProtocolSession,
103
+ deliver: TOutboundDeliver,
104
+ driverId?: TDriverId,
105
+ reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
106
+ surface?: TUsageSurface,
107
+ ): (data: string) => void {
108
+ return (data: string): void => {
109
+ const msg = parseClientMessage(data, deliver);
110
+ if (!msg) return;
111
+ handleClientMessage(session, deliver, msg, driverId, reporters, surface);
112
+ };
113
+ }
114
+
115
+ const EMPTY_USAGE_REPORTERS: IUsageQueryReporters = {
116
+ personalUsageReporter: undefined,
117
+ usageReporter: undefined,
118
+ storedSessionUsageReporter: undefined,
119
+ };
120
+
121
+ /**
122
+ * Route a parsed client message to the session (control/query/background/prompt-response). Exported for E4:
123
+ * the {@link SessionResumeBridge} intercepts `resume`/`ack` itself and delegates everything else here.
124
+ */
125
+ export function handleClientMessage(
126
+ session: IProtocolSession,
127
+ deliver: TOutboundDeliver,
128
+ msg: TClientMessage,
129
+ driverId?: TDriverId,
130
+ reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
131
+ surface?: TUsageSurface,
132
+ ): void {
133
+ if (handleUsageQueryMessage(session, deliver, msg, reporters)) {
134
+ return;
135
+ }
136
+ if (isSessionControlMessage(msg)) {
137
+ handleSessionControlMessage(session, deliver, msg, driverId, surface);
138
+ return;
139
+ }
140
+ if (isSessionQueryMessage(msg)) {
141
+ handleSessionQueryMessage(session, deliver, msg);
142
+ return;
143
+ }
144
+ if (isBackgroundQueryMessage(msg)) {
145
+ handleBackgroundQueryMessage(session, deliver, msg);
146
+ return;
147
+ }
148
+ if (isBackgroundControlMessage(msg)) {
149
+ handleBackgroundControlMessage(session, deliver, msg);
150
+ return;
151
+ }
152
+ if (isPromptResponseMessage(msg)) {
153
+ handlePromptResponseMessage(session, msg, driverId);
154
+ return;
155
+ }
156
+ const unknownType = (msg as { type: string }).type;
157
+ deliver({ type: 'protocol_error', message: `Unknown message type: ${unknownType}` });
158
+ }
159
+
160
+ function isSessionControlMessage(
161
+ msg: TClientMessage,
162
+ ): msg is Extract<TClientMessage, { type: 'submit' | 'command' | 'abort' | 'cancel-queue' }> {
163
+ return (
164
+ msg.type === 'submit' ||
165
+ msg.type === 'command' ||
166
+ msg.type === 'abort' ||
167
+ msg.type === 'cancel-queue'
168
+ );
169
+ }
170
+
171
+ function isBackgroundQueryMessage(
172
+ msg: TClientMessage,
173
+ ): msg is Extract<
174
+ TClientMessage,
175
+ | { type: 'get-background-tasks' | 'get-background-task' | 'read-background-task-log' }
176
+ | { type: 'get-background-job-groups' | 'get-background-job-group' | 'wait-background-job-group' }
177
+ > {
178
+ return (
179
+ msg.type === 'get-background-tasks' ||
180
+ msg.type === 'get-background-task' ||
181
+ msg.type === 'read-background-task-log' ||
182
+ msg.type === 'get-background-job-groups' ||
183
+ msg.type === 'get-background-job-group' ||
184
+ msg.type === 'wait-background-job-group'
185
+ );
186
+ }
187
+
188
+ function isBackgroundControlMessage(
189
+ msg: TClientMessage,
190
+ ): msg is Extract<
191
+ TClientMessage,
192
+ { type: 'cancel-background-task' | 'close-background-task' | 'send-background-task' }
193
+ > {
194
+ return (
195
+ msg.type === 'cancel-background-task' ||
196
+ msg.type === 'close-background-task' ||
197
+ msg.type === 'send-background-task'
198
+ );
199
+ }
200
+
201
+ function isPromptResponseMessage(
202
+ msg: TClientMessage,
203
+ ): msg is Extract<TClientMessage, { type: 'permission-response' | 'ask-response' }> {
204
+ return msg.type === 'permission-response' || msg.type === 'ask-response';
205
+ }
206
+
207
+ /**
208
+ * REMOTE-007: a driving client answered a pending prompt by id. `resolvePermission`/`resolveAsk` are
209
+ * idempotent — a stale id (already answered by another surface, or drained) is a safe no-op, so no
210
+ * acknowledgement is needed; the resulting `prompt_resolved` server event is the shared signal.
211
+ */
212
+ function handlePromptResponseMessage(
213
+ session: IProtocolSession,
214
+ msg: Extract<TClientMessage, { type: 'permission-response' | 'ask-response' }>,
215
+ driverId?: TDriverId,
216
+ ): void {
217
+ // REMOTE-014 E5: record the SERVER-ASSIGNED answering driver (not client-sent) on `prompt_resolved`.
218
+ if (msg.type === 'permission-response') {
219
+ session.resolvePermission(msg.id, msg.result, driverId);
220
+ } else {
221
+ session.resolveAsk(msg.id, msg.response, driverId);
222
+ }
223
+ }
224
+
225
+ function handleSessionControlMessage(
226
+ session: IProtocolSession,
227
+ deliver: TOutboundDeliver,
228
+ msg: Extract<TClientMessage, { type: 'submit' | 'command' | 'abort' | 'cancel-queue' }>,
229
+ driverId?: TDriverId,
230
+ surface?: TUsageSurface,
231
+ ): void {
232
+ if (msg.type === 'submit') {
233
+ // TRANS-008 (issue #2045). A TYPE check, not a falsy one: `{}`, `[]`, `42` and `true` are truthy
234
+ // and passed the previous `!msg.prompt` guard. It matters more here than at the other payload
235
+ // fields because the value does not stop at this session — it is re-emitted as `user_message` to
236
+ // EVERY attached client inside a frame whose `content` is declared `string`.
237
+ if (typeof msg.prompt !== 'string' || msg.prompt.length === 0) {
238
+ deliver({ type: 'protocol_error', message: 'prompt must be a non-empty string' });
239
+ return;
240
+ }
241
+ // REMOTE-014 E5: attribute this remote turn to the SERVER-ASSIGNED driver id (never a client-sent one).
242
+ const submitOptions = {
243
+ ...(driverId ? { driverId } : {}),
244
+ ...(surface ? { surface } : {}),
245
+ };
246
+ session
247
+ .submit(
248
+ msg.prompt,
249
+ undefined,
250
+ undefined,
251
+ Object.keys(submitOptions).length > 0 ? submitOptions : undefined,
252
+ )
253
+ .catch((error: Error) => {
254
+ deliver({ type: 'protocol_error', message: error.message });
255
+ });
256
+ } else if (msg.type === 'command') {
257
+ if (!msg.name) {
258
+ deliver({ type: 'protocol_error', message: 'name is required' });
259
+ return;
260
+ }
261
+ // REMOTE-003: a transport-origin command is tagged `'remote'` (optional policy, allow-by-default;
262
+ // 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(
264
+ (result) => {
265
+ deliver({
266
+ type: 'command_result',
267
+ name: msg.name,
268
+ message: result?.message ?? `Unknown command: ${msg.name}`,
269
+ success: result?.success ?? false,
270
+ data: result?.data,
271
+ });
272
+ },
273
+ (error: Error) => {
274
+ deliver({ type: 'protocol_error', message: error.message });
275
+ },
276
+ );
277
+ } else if (msg.type === 'abort') {
278
+ session.abort();
279
+ } else {
280
+ session.cancelQueue();
281
+ }
282
+ }
@@ -0,0 +1,42 @@
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
+
5
+ type TSessionQueryMessage = Extract<
6
+ TClientMessage,
7
+ {
8
+ type:
9
+ 'get-messages' | 'get-context' | 'get-executing' | 'get-pending' | 'get-execution-workspace';
10
+ }
11
+ >;
12
+
13
+ export function isSessionQueryMessage(msg: TClientMessage): msg is TSessionQueryMessage {
14
+ return (
15
+ msg.type === 'get-messages' ||
16
+ msg.type === 'get-context' ||
17
+ msg.type === 'get-executing' ||
18
+ msg.type === 'get-pending' ||
19
+ msg.type === 'get-execution-workspace'
20
+ );
21
+ }
22
+
23
+ export function handleSessionQueryMessage(
24
+ session: IProtocolSession,
25
+ deliver: TOutboundDeliver,
26
+ msg: TSessionQueryMessage,
27
+ ): void {
28
+ if (msg.type === 'get-messages') {
29
+ deliver({ type: 'messages', messages: session.getMessages() });
30
+ } else if (msg.type === 'get-context') {
31
+ deliver({ type: 'context', state: session.getContextState() });
32
+ } else if (msg.type === 'get-executing') {
33
+ deliver({ type: 'executing', executing: session.isExecuting() });
34
+ } else if (msg.type === 'get-execution-workspace') {
35
+ deliver({
36
+ type: 'execution_workspace_event',
37
+ snapshot: session.getExecutionWorkspaceSnapshot(),
38
+ });
39
+ } else {
40
+ deliver({ type: 'pending', pending: session.getPendingPrompt() });
41
+ }
42
+ }