@robota-sdk/agent-transport 3.0.0-beta.100

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 (93) hide show
  1. package/CHANGELOG.md +1137 -0
  2. package/LICENSE +661 -0
  3. package/README.md +121 -0
  4. package/dist/browser/client.d.ts +2 -0
  5. package/dist/browser/client.js +1 -0
  6. package/dist/browser/index.d.ts +624 -0
  7. package/dist/browser/index.d.ts.map +1 -0
  8. package/dist/browser/index.js +2 -0
  9. package/dist/browser/index.js.map +1 -0
  10. package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
  11. package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
  12. package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
  13. package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
  14. package/dist/node/index.cjs +1 -0
  15. package/dist/node/index.d.cts +1115 -0
  16. package/dist/node/index.d.cts.map +1 -0
  17. package/dist/node/index.d.ts +1115 -0
  18. package/dist/node/index.d.ts.map +1 -0
  19. package/dist/node/index.js +2 -0
  20. package/dist/node/index.js.map +1 -0
  21. package/dist/node/node.cjs +1 -0
  22. package/dist/node/node.d.cts +317 -0
  23. package/dist/node/node.d.cts.map +1 -0
  24. package/dist/node/node.d.ts +317 -0
  25. package/dist/node/node.d.ts.map +1 -0
  26. package/dist/node/node.js +2 -0
  27. package/dist/node/node.js.map +1 -0
  28. package/package.json +122 -0
  29. package/src/__tests__/__fixtures__/korean-text.json +6 -0
  30. package/src/__tests__/access-token-verifier.test.ts +672 -0
  31. package/src/__tests__/admission.test.ts +130 -0
  32. package/src/__tests__/app-tool-messages.test.ts +463 -0
  33. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  34. package/src/__tests__/channel-frames.test.ts +215 -0
  35. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  36. package/src/__tests__/file-transfer.test.ts +281 -0
  37. package/src/__tests__/handoff-chunking.test.ts +261 -0
  38. package/src/__tests__/handoff-manifest.test.ts +52 -0
  39. package/src/__tests__/history-attached-budget.test.ts +216 -0
  40. package/src/__tests__/message-decoders.test.ts +870 -0
  41. package/src/__tests__/outbound-delivery.test.ts +558 -0
  42. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  43. package/src/__tests__/permission-summary.test.ts +18 -0
  44. package/src/__tests__/personal-usage-report.test.ts +193 -0
  45. package/src/__tests__/project-read-messages.test.ts +118 -0
  46. package/src/__tests__/resume-buffer.test.ts +93 -0
  47. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  48. package/src/__tests__/session-directory-messages.test.ts +322 -0
  49. package/src/__tests__/session-error-classification.test.ts +113 -0
  50. package/src/__tests__/session-event-delivery.test.ts +101 -0
  51. package/src/__tests__/session-history-wire.test.ts +364 -0
  52. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  53. package/src/__tests__/session-message-handler-observe.test.ts +223 -0
  54. package/src/__tests__/session-message-handler.test.ts +726 -0
  55. package/src/__tests__/session-resume-bridge.test.ts +472 -0
  56. package/src/__tests__/settings-messages.test.ts +193 -0
  57. package/src/__tests__/stdio-carrier.test.ts +85 -0
  58. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  59. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  60. package/src/__tests__/ws-broadcast-events.test.ts +94 -0
  61. package/src/app-tool-messages.ts +140 -0
  62. package/src/background-messages.ts +169 -0
  63. package/src/channel-frames.ts +182 -0
  64. package/src/client.ts +17 -0
  65. package/src/handoff-chunking.ts +275 -0
  66. package/src/index.ts +135 -0
  67. package/src/loop-control-messages.ts +34 -0
  68. package/src/message-decoders.ts +485 -0
  69. package/src/message-parser.ts +33 -0
  70. package/src/node/access-token-verifier.ts +363 -0
  71. package/src/node/admission.ts +153 -0
  72. package/src/node/bearer-resource-gate.ts +331 -0
  73. package/src/node/file-transfer.ts +530 -0
  74. package/src/node/handoff-manifest.ts +77 -0
  75. package/src/node/index.ts +45 -0
  76. package/src/node/stdio-carrier.ts +59 -0
  77. package/src/observer-messages.ts +47 -0
  78. package/src/open-prompts.ts +76 -0
  79. package/src/outbound-delivery.ts +240 -0
  80. package/src/peer-message-ledger.ts +193 -0
  81. package/src/permission-summary.ts +18 -0
  82. package/src/project-read-messages.ts +74 -0
  83. package/src/protocol-session.ts +56 -0
  84. package/src/resume-buffer.ts +123 -0
  85. package/src/session-directory-messages.ts +162 -0
  86. package/src/session-error-classification.ts +91 -0
  87. package/src/session-events.ts +290 -0
  88. package/src/session-message-handler.ts +413 -0
  89. package/src/session-query-messages.ts +160 -0
  90. package/src/session-resume-bridge.ts +275 -0
  91. package/src/settings-messages.ts +103 -0
  92. package/src/usage-messages.ts +195 -0
  93. package/src/wire-messages.ts +436 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The input bridge and close observation a stdio protocol carrier shares.
3
+ *
4
+ * A protocol SDK that reads a stream owns what it reads: closing the SDK connection destroys its
5
+ * source. Handing it the process input directly would let one carrier's `stop()` destroy `process.stdin`
6
+ * for the whole process, so the carrier reads a `PassThrough` bridge fed by the input instead. Piping
7
+ * also makes EOF observable before the SDK starts reading and keeps early frames, with backpressure,
8
+ * until it does.
9
+ *
10
+ * This module only attaches and detaches. What a close or failure means — settle a promise, cancel a
11
+ * startup, end the process — stays with each carrier.
12
+ */
13
+
14
+ import { PassThrough } from 'node:stream';
15
+
16
+ import type { Readable, Writable } from 'node:stream';
17
+
18
+ export interface IStdioCarrierHandlers {
19
+ /** The peer ended its side: `end` or `close` on the input. May fire more than once. */
20
+ readonly onInputClose: () => void;
21
+ /** The output closed. */
22
+ readonly onOutputClose: () => void;
23
+ /** The output failed to accept a write. */
24
+ readonly onOutputError: (error: Error) => void;
25
+ }
26
+
27
+ export interface IStdioCarrier {
28
+ /** Hand this to the protocol SDK in place of the input; destroying it never destroys the input. */
29
+ readonly bridge: PassThrough;
30
+ /** Remove the listeners, unpipe and destroy the bridge. Safe to call again; the streams stay open. */
31
+ release(): void;
32
+ }
33
+
34
+ export function attachStdioCarrier(
35
+ input: Readable,
36
+ output: Writable,
37
+ handlers: IStdioCarrierHandlers,
38
+ ): IStdioCarrier {
39
+ const bridge = new PassThrough();
40
+ let released = false;
41
+ input.once('end', handlers.onInputClose);
42
+ input.once('close', handlers.onInputClose);
43
+ output.once('close', handlers.onOutputClose);
44
+ output.on('error', handlers.onOutputError);
45
+ input.pipe(bridge);
46
+ return {
47
+ bridge,
48
+ release() {
49
+ if (released) return;
50
+ released = true;
51
+ input.off('end', handlers.onInputClose);
52
+ input.off('close', handlers.onInputClose);
53
+ output.off('close', handlers.onOutputClose);
54
+ output.off('error', handlers.onOutputError);
55
+ input.unpipe(bridge);
56
+ bridge.destroy();
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,47 @@
1
+ import type { TClientMessage } from './wire-messages.js';
2
+
3
+ /**
4
+ * The only inbound messages an observer may send: reads of this session. An allowlist, so a message
5
+ * type added later is refused to observers until someone decides it is a read. `get-prompts` is left
6
+ * out: an observer never receives prompts, open ones included.
7
+ *
8
+ * The host refuses anything else from an observer, and a client in observe mode sends only these, so
9
+ * both sides read this one list.
10
+ */
11
+ export const OBSERVER_MESSAGES: ReadonlySet<TClientMessage['type']> = new Set<
12
+ TClientMessage['type']
13
+ >([
14
+ 'get-messages',
15
+ 'get-history',
16
+ 'get-context',
17
+ 'get-status',
18
+ 'get-commands',
19
+ 'list-models',
20
+ 'get-executing',
21
+ 'get-pending',
22
+ 'get-execution-workspace',
23
+ 'read-execution-detail',
24
+ 'get-agent-definitions',
25
+ 'get-usage-report',
26
+ 'list-sessions',
27
+ 'get-background-tasks',
28
+ 'get-background-task',
29
+ 'get-background-job-groups',
30
+ 'get-background-job-group',
31
+ 'wait-background-job-group',
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',
42
+ ]);
43
+
44
+ /** Whether an observer may send a message of this type. */
45
+ export function isObserverMessageType(type: string): boolean {
46
+ return (OBSERVER_MESSAGES as ReadonlySet<string>).has(type);
47
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * #3189: the permission and ask prompts a session has open, as the frames that asked them.
3
+ *
4
+ * The session keeps only the promise each prompt waits on, and emits the prompt once, when it is
5
+ * asked. A client that attaches later would never see a prompt already open. This keeps what the
6
+ * transport forwarded, per session, for a `get-prompts` reply.
7
+ *
8
+ * It is held by the connections that receive prompts and learns everything through their own
9
+ * subscriptions, so it never listens to the session itself. That matters: a session asks only while
10
+ * someone listens for prompts, and a listener of its own here would keep an unattended session's
11
+ * prompt open instead of failing it closed. When the last such connection goes, the session settles
12
+ * every open prompt, so nothing is kept.
13
+ */
14
+
15
+ import type { IProtocolSession } from './protocol-session.js';
16
+ import type { TServerMessage } from './wire-messages.js';
17
+
18
+ export type TOpenPromptFrame = Extract<
19
+ TServerMessage,
20
+ { type: 'permission_request' | 'ask_request' }
21
+ >;
22
+
23
+ interface IOpenPrompts {
24
+ readonly frames: Map<string, TOpenPromptFrame>;
25
+ holders: number;
26
+ }
27
+
28
+ const openPromptsBySession = new WeakMap<IProtocolSession, IOpenPrompts>();
29
+
30
+ /** One connection's hold on its session's open prompts; every holder reports what it forwards. */
31
+ export interface IOpenPromptsHold {
32
+ /** A prompt was asked. */
33
+ record(frame: TOpenPromptFrame): void;
34
+ /** A prompt was settled, by an answer or by the session. */
35
+ forget(id: string): void;
36
+ /** The host made another session current; the previous session's prompts are settled. */
37
+ clear(): void;
38
+ /** This connection no longer receives prompts. */
39
+ release(): void;
40
+ }
41
+
42
+ /** Called by a connection that receives prompts, before it subscribes to them. */
43
+ export function holdOpenPrompts(session: IProtocolSession): IOpenPromptsHold {
44
+ let open = openPromptsBySession.get(session);
45
+ if (open === undefined) {
46
+ open = { frames: new Map(), holders: 0 };
47
+ openPromptsBySession.set(session, open);
48
+ }
49
+ const held = open;
50
+ held.holders += 1;
51
+ let released = false;
52
+ return {
53
+ record: (frame) => {
54
+ if (!released) held.frames.set(frame.event.id, frame);
55
+ },
56
+ forget: (id) => {
57
+ held.frames.delete(id);
58
+ },
59
+ clear: () => {
60
+ held.frames.clear();
61
+ },
62
+ release: () => {
63
+ if (released) return;
64
+ released = true;
65
+ held.holders -= 1;
66
+ if (held.holders === 0 && openPromptsBySession.get(session) === held) {
67
+ openPromptsBySession.delete(session);
68
+ }
69
+ },
70
+ };
71
+ }
72
+
73
+ /** The prompts open now, oldest first; none when no connection that receives prompts is attached. */
74
+ export function listOpenPrompts(session: IProtocolSession): readonly TOpenPromptFrame[] {
75
+ return [...(openPromptsBySession.get(session)?.frames.values() ?? [])];
76
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The connection-scoped outbound delivery boundary (ARCH-030).
3
+ *
4
+ * Every outbound `TServerMessage` on one connection — session-event fan-out AND every reply to an
5
+ * inbound frame — goes through ONE boundary that owns carrier-failure containment. Before this
6
+ * existed, `subscribeSessionEvents` guarded its sends while the reply paths received the raw carrier
7
+ * `send`, so a reply resolving after a disconnect threw from a Promise continuation, escaped as an
8
+ * unhandled rejection, and left the carrier's cleanup — written, idempotent, and waiting — unnotified.
9
+ *
10
+ * WHO BUILDS IT. The **carrier**, from its own private sink and its own failure policy, and passes the
11
+ * result down into the protocol handler. Not the other way round: the raw sink and the "what does a
12
+ * failed send mean for this connection" decision both belong to the carrier, and handing the protocol
13
+ * layer a raw sink so it can hand a wrapper back leaves the raw sink reachable — which is the hole a
14
+ * twelfth reply family walks through next year.
15
+ *
16
+ * WHY IT IS BRANDED. {@link createOutboundDelivery} is the only producer of {@link TOutboundDeliver}, so
17
+ * a plain `(message: TServerMessage) => void` is **refused by the compiler** wherever a boundary is
18
+ * required. That turns "a new raw-send continuation cannot be added silently" from a scan that a rename
19
+ * defeats into a type error at the call site. `src/__tests__` is inside this package's `tsconfig.json`
20
+ * `include`, so the `@ts-expect-error` case pinning it is checked by `tsgo --noEmit`.
21
+ *
22
+ * IT LATCHES. A boundary reports AT MOST ONE delivery failure; after the first, it is closed and every
23
+ * subsequent frame is dropped without a further report. All three carriers already treat a delivery
24
+ * failure as terminal (`WsSessionDelivery.close` → `socket.close(1011)`, `WebRtcDeliveryLifecycle` →
25
+ * `channel.close()`, `PairingGate` → `pairingChannel.close`) and each had grown its own latch to
26
+ * suppress the repeats; the latch belongs upstream of all three. `SessionResumeBridge` already behaved
27
+ * this way, so the handler path converges onto the bridge's semantics rather than the reverse.
28
+ *
29
+ * A boundary is not reusable across carriers: a latched one stays latched. `SessionResumeBridge` builds
30
+ * a fresh boundary per `attach`, which is what un-latches the session after a reconnect.
31
+ */
32
+
33
+ import type { TServerMessage } from './wire-messages.js';
34
+
35
+ declare const outboundDeliveryBrand: unique symbol;
36
+
37
+ /**
38
+ * Deliver one outbound frame on a connection. A carrier failure is REPORTED through the boundary's
39
+ * error handler, never thrown at the caller — so a reply resolving after a disconnect cannot escape as
40
+ * an unhandled rejection or out of the carrier's inbound listener.
41
+ *
42
+ * Only {@link createOutboundDelivery} produces one. The brand is the mechanism, not decoration: it is
43
+ * what makes a raw carrier `send` unusable where a boundary is required.
44
+ */
45
+ export type TOutboundDeliver = ((message: TServerMessage) => void) & {
46
+ readonly [outboundDeliveryBrand]: true;
47
+
48
+ /**
49
+ * Bytes this carrier has ACCEPTED and not yet written to the peer — or `undefined` when it cannot
50
+ * say.
51
+ *
52
+ * `deliver` returning is not delivery. Both carriers hand this boundary a fire-and-forget sink, so
53
+ * a frame that has "been sent" may be sitting in a socket buffer the boundary cannot see. Anything
54
+ * this boundary counted itself would be a count of what it HANDED OVER, which is a different
55
+ * quantity from what the peer has not read — and a budget over the wrong quantity reports a
56
+ * healthy connection for a peer that stopped reading (ARCH-030 / issue #1734).
57
+ *
58
+ * `undefined` is deliberately not `0`. A carrier that cannot report backpressure and a carrier with
59
+ * nothing pending are different states, and collapsing them would let "unknown" satisfy any
60
+ * threshold placed on this number.
61
+ */
62
+ readonly pendingBytes: () => number | undefined;
63
+ };
64
+
65
+ /**
66
+ * Observe THIS connection's first outbound delivery failure. `event` is the `type` of the frame that
67
+ * could not be delivered — for the fan-out, the frame a session event was forwarded as (usually, not
68
+ * always, the event's own name), and the reply's type for a reply (`command_result`, `protocol_error`, …).
69
+ *
70
+ * Required, never optional: a carrier that could opt out of observing its own delivery failures is the
71
+ * silent-failure shape this boundary exists to remove.
72
+ */
73
+ export type TDeliveryErrorHandler = (error: Error, event: TServerMessage['type']) => void;
74
+
75
+ /**
76
+ * Build the outbound delivery boundary for ONE connection from that connection's raw sink and its
77
+ * failure policy.
78
+ *
79
+ * @param send - the carrier's raw sink. May throw; that is what makes it raw.
80
+ * @param onDeliveryError - the carrier's failure policy, invoked at most once. A handler that itself
81
+ * throws is isolated: a diagnostic cannot reverse an already-committed session operation.
82
+ * @param pendingBytes - the carrier's own backpressure reading. Optional because not every carrier
83
+ * has one; omitted, the boundary answers `undefined` rather than inventing a number.
84
+ */
85
+ /**
86
+ * Default outbound backpressure budget, in bytes the carrier has accepted and not yet written.
87
+ *
88
+ * 8 MiB. The number is a policy, not a measurement, and it is stated here rather than inlined so a
89
+ * reader can find the one place that decides it. It is well above any single frame this protocol
90
+ * produces and well below a figure at which a non-reading peer could exhaust the host — the property
91
+ * that matters is that SOME finite budget exists, because the previous behaviour was unbounded.
92
+ *
93
+ * A carrier that cannot report `pendingBytes` is not subject to it: `undefined` is unknown, not zero,
94
+ * so no threshold applies to it. That is deliberate and it is a gap — a carrier with no backpressure
95
+ * reading has no budget — recorded rather than hidden behind a default of `0`, which would refuse
96
+ * every frame on such a carrier.
97
+ */
98
+ const BYTES_PER_MIB = Number('1024') * Number('1024');
99
+ export const DEFAULT_MAX_PENDING_BYTES = Number('8') * BYTES_PER_MIB;
100
+
101
+ /**
102
+ * Backpressure budget for a terminal attached to a session on the same host, in either role.
103
+ *
104
+ * 1 MiB, tighter than the default: an attached terminal reads a local socket, so a backlog this large
105
+ * means it stopped reading rather than that a network is slow. Exceeding it closes that connection
106
+ * only; the session never waits for a reader, so other surfaces keep streaming.
107
+ */
108
+ export const ATTACHED_SURFACE_MAX_PENDING_BYTES = BYTES_PER_MIB;
109
+
110
+ /**
111
+ * Is this carrier holding more than the budget allows?
112
+ *
113
+ * Exported because a WebSocket carries TWO kinds of outbound frame over one socket — the text
114
+ * protocol and TRANS-001's payload channels — and only one of them goes through
115
+ * {@link createOutboundDelivery}. A budget the binary path re-implemented would be a second opinion
116
+ * about one socket's backpressure; a budget it skipped would leave the same non-reading peer
117
+ * unbounded on the other half of the connection.
118
+ *
119
+ * `undefined` is unknown, not zero: a carrier that cannot report backpressure is never over budget.
120
+ */
121
+ export function isOverPendingBudget(
122
+ pending: number | undefined,
123
+ limit: number = DEFAULT_MAX_PENDING_BYTES,
124
+ ): boolean {
125
+ return pending !== undefined && pending > limit;
126
+ }
127
+
128
+ /**
129
+ * Default outbound DRAIN budget (issue #2306): how long a carrier may stay non-empty before the peer
130
+ * is treated as not reading.
131
+ *
132
+ * 120 seconds. The byte budget bounds HOW MUCH a peer holds; it cannot bound HOW LONG, because
133
+ * `bufferedAmount` is a quantity with no history — a peer draining just enough to stay under 8 MiB
134
+ * forever is never over it. The figure is generous on purpose: a legitimately slow link drains to
135
+ * zero between bursts, so the clock resets, whereas a peer that keeps SOMETHING pending for two
136
+ * minutes straight is holding a session, its resume buffer, and a socket it does not intend to read.
137
+ * A badly chosen figure here disconnects real users, which is why it is one named constant and an
138
+ * argument, never an inline number. `Infinity` disables the budget.
139
+ */
140
+ export const DEFAULT_MAX_PENDING_MS = Number('120') * Number('1000');
141
+
142
+ /**
143
+ * The drain clock the boundary keeps for a carrier (issue #2306). One timestamp, not per-frame
144
+ * bookkeeping: it starts on the first reading that finds the carrier non-empty, RESETS only when a
145
+ * reading finds it empty (a peer that drains to one byte and stalls again has not drained), and is
146
+ * consulted synchronously at each send — the boundary has no timer and gains none here.
147
+ */
148
+ export interface IPendingStallClock {
149
+ readonly maxPendingMs: number;
150
+ /**
151
+ * Record one reading of the carrier's pending bytes. Returns how long the carrier has been
152
+ * continuously non-empty when that exceeds the budget; `undefined` otherwise. An `undefined`
153
+ * reading is unknown, not zero: it neither starts nor resets the clock.
154
+ */
155
+ observe: (pending: number | undefined) => number | undefined;
156
+ }
157
+
158
+ export function createPendingStallClock(
159
+ maxPendingMs: number = DEFAULT_MAX_PENDING_MS,
160
+ now: () => number = Date.now,
161
+ ): IPendingStallClock {
162
+ let nonEmptySince: number | undefined;
163
+ return {
164
+ maxPendingMs,
165
+ observe(pending) {
166
+ if (pending === undefined) return undefined;
167
+ if (pending === 0) {
168
+ nonEmptySince = undefined;
169
+ return undefined;
170
+ }
171
+ const at = now();
172
+ nonEmptySince ??= at;
173
+ const stalledMs = at - nonEmptySince;
174
+ return stalledMs > maxPendingMs ? stalledMs : undefined;
175
+ },
176
+ };
177
+ }
178
+
179
+ export function createOutboundDelivery(
180
+ send: (message: TServerMessage) => void,
181
+ onDeliveryError: TDeliveryErrorHandler,
182
+ pendingBytes?: () => number | undefined,
183
+ maxPendingBytes: number = DEFAULT_MAX_PENDING_BYTES,
184
+ stallClock: IPendingStallClock = createPendingStallClock(),
185
+ ): TOutboundDeliver {
186
+ let closed = false;
187
+ const deliver = (message: TServerMessage): void => {
188
+ if (closed) return;
189
+ // The budget is checked BEFORE the send, not after: a peer that has stopped reading is detected
190
+ // by what the carrier is still holding, and adding one more frame first would make the boundary
191
+ // the last contributor to the overflow it is reporting.
192
+ const pending = pendingBytes?.();
193
+ if (isOverPendingBudget(pending, maxPendingBytes)) {
194
+ closed = true;
195
+ reportFailure(
196
+ new Error(
197
+ `outbound backpressure budget exceeded: ${pending} byte(s) pending, limit ${maxPendingBytes}. ` +
198
+ 'The peer has accepted frames it is not reading.',
199
+ ),
200
+ message.type,
201
+ );
202
+ return;
203
+ }
204
+ // Issue #2306: the same reading, judged by duration. A peer under the byte budget that has kept
205
+ // the carrier non-empty for longer than the drain budget is not reading either.
206
+ const stalledMs = stallClock.observe(pending);
207
+ if (stalledMs !== undefined) {
208
+ closed = true;
209
+ reportFailure(
210
+ new Error(
211
+ `outbound drain budget exceeded: ${pending} byte(s) pending for ${stalledMs} ms, limit ${stallClock.maxPendingMs} ms. ` +
212
+ 'The peer is reading too slowly to ever drain what it accepted.',
213
+ ),
214
+ message.type,
215
+ );
216
+ return;
217
+ }
218
+ try {
219
+ send(message);
220
+ } catch (error) {
221
+ // allow-fallback: this IS the boundary — a carrier send failure is a CARRIER lifecycle failure,
222
+ // reported to the carrier's own policy (which closes the connection). It is never swallowed, and
223
+ // re-throwing it would fail a session operation that has already committed.
224
+ closed = true;
225
+ reportFailure(error instanceof Error ? error : new Error(String(error)), message.type);
226
+ }
227
+ };
228
+ function reportFailure(error: Error, event: TServerMessage['type']): void {
229
+ try {
230
+ onDeliveryError(error, event);
231
+ } catch {
232
+ // allow-fallback: the carrier-owned diagnostic is the LAST step of a failure already being
233
+ // reported. A throw from it has nowhere left to go, and letting it out would make a committed
234
+ // session operation fail for the sake of a diagnostic.
235
+ }
236
+ }
237
+ return Object.assign(deliver, {
238
+ pendingBytes: (): number | undefined => pendingBytes?.(),
239
+ }) as TOutboundDeliver;
240
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * PEER-001 (#1809): the receiver's record of what it has already taken responsibility for.
3
+ *
4
+ * ## What this decides, and why it is not the carrier's job
5
+ *
6
+ * The issue requires delivery, acknowledgement, duplicate, retry and shutdown to produce
7
+ * "deterministic, documented outcomes". Every one of those is a question about what the receiver has
8
+ * SEEN BEFORE, which no carrier can answer: a WebRTC data channel redelivers on reconnect, a retry
9
+ * repeats a message the sender already sent, and neither the socket nor the frame codec has any
10
+ * memory to consult. So the decision lives here, once, and the carriers stay dumb.
11
+ *
12
+ * ## The rules, stated rather than emergent
13
+ *
14
+ * - **A repeated `id` is a duplicate**, and the ledger returns the SAME ack it returned the first
15
+ * time. That is what makes a retry safe: the sender learns the message landed, and the receiver
16
+ * does not deliver it twice. An ack that merely said "duplicate" without the original state would
17
+ * force the sender to guess whether the first attempt was accepted or refused.
18
+ * - **Sequence is per origin.** Two peers are independent senders; a shared counter would make one
19
+ * peer's traffic look like the other's gap.
20
+ * - **A gap is REPORTED, never silently reordered.** If sequence 5 arrives while 4 has not, the
21
+ * ledger says so. Buffering-and-reordering is a policy the session layer may choose; inventing it
22
+ * here would hide a lost message behind an apparent success, which is the failure mode this whole
23
+ * contract exists to avoid.
24
+ * - **Nothing is remembered across shutdown.** The ledger is per-connection state by construction —
25
+ * a peer that reconnects gets a fresh sequence space, and the session layer decides what to do
26
+ * with a peer that restarts mid-conversation.
27
+ */
28
+
29
+ import type {
30
+ IPeerMessage,
31
+ IPeerMessageAck,
32
+ TPeerDeliveryState,
33
+ } from '@robota-sdk/agent-interface-session-mobility';
34
+
35
+ /**
36
+ * What the receiver has seen from one origin.
37
+ *
38
+ * `delivered` is a SET, not just a high-water mark, and that distinction is the whole of review
39
+ * finding #1. A high-water mark cannot tell "sequence 2 was already used" from "sequence 2 is the
40
+ * gap we reported and it has just arrived" — and answering the second as if it were the first
41
+ * refuses a message that was never delivered, permanently. `highestSequence` is kept alongside it
42
+ * because reporting a gap needs the frontier, not the membership.
43
+ *
44
+ * Exported because it is reachable from `IPeerMessageLedger`. A public interface whose field type is
45
+ * unexported is a name a consumer cannot write down (review finding #2). Measured: `tsdown` inlines
46
+ * it into the bundled declaration so no TS4033 is raised today — the finding's build-breakage claim
47
+ * does not reproduce — but a type nobody can name is still a bad public surface.
48
+ */
49
+ export interface IOriginRecord {
50
+ /** The highest sequence delivered from this origin — the frontier a gap is measured against. */
51
+ highestSequence: number;
52
+ /** Every sequence actually delivered. Membership, not a range: gaps get filled out of order. */
53
+ delivered: Set<number>;
54
+ /** Acks already issued, by message id, so a retry gets the original answer back. */
55
+ issued: Map<string, IPeerMessageAck>;
56
+ }
57
+
58
+ /** Per-connection receive state. Created empty; discarded when the connection ends. */
59
+ export interface IPeerMessageLedger {
60
+ readonly origins: Map<string, IOriginRecord>;
61
+ }
62
+
63
+ export function createPeerMessageLedger(): IPeerMessageLedger {
64
+ return { origins: new Map() };
65
+ }
66
+
67
+ /** Why a message was not accepted, when it was not. */
68
+ export interface IPeerMessageRejection {
69
+ readonly state: Extract<TPeerDeliveryState, 'refused' | 'failed'>;
70
+ readonly reason: string;
71
+ }
72
+
73
+ export interface IPeerMessageVerdict {
74
+ readonly ack: IPeerMessageAck;
75
+ /**
76
+ * Should the session deliver this to the agent?
77
+ *
78
+ * False for a duplicate and for a refusal. Separate from `ack.state` because a caller that
79
+ * re-derived it from the state string would have to re-list which states mean "deliver", and that
80
+ * list is exactly what drifts.
81
+ */
82
+ readonly deliver: boolean;
83
+ /**
84
+ * Set when this message's sequence is ahead of the last one delivered from its origin.
85
+ *
86
+ * Reported, not resolved. The session layer decides whether a gap is worth waiting for; the
87
+ * ledger's job is to make sure it is never invisible.
88
+ */
89
+ readonly gapBefore?: number;
90
+ }
91
+
92
+ /**
93
+ * Record a message and say what should happen to it.
94
+ *
95
+ * Pure with respect to its inputs except for the ledger it is handed, which it updates in place —
96
+ * the caller owns the ledger's lifetime, so a connection ending is a reference going away rather
97
+ * than a cleanup step someone can forget.
98
+ */
99
+ export function admitPeerMessage(
100
+ ledger: IPeerMessageLedger,
101
+ message: IPeerMessage,
102
+ rejection?: IPeerMessageRejection,
103
+ ): IPeerMessageVerdict {
104
+ const originId = message.origin.sessionId;
105
+ let record = ledger.origins.get(originId);
106
+ if (record === undefined) {
107
+ record = { highestSequence: 0, delivered: new Set(), issued: new Map() };
108
+ ledger.origins.set(originId, record);
109
+ }
110
+
111
+ // A repeated id is answered with the ORIGINAL verdict, before anything else is considered: a
112
+ // retry must not be re-judged, or a message accepted once could be refused on its second arrival
113
+ // and the sender would have two contradictory answers for one message.
114
+ const previous = record.issued.get(message.id);
115
+ if (previous !== undefined) {
116
+ return { ack: { ...previous, state: 'duplicate' }, deliver: false };
117
+ }
118
+
119
+ if (rejection !== undefined) {
120
+ const ack: IPeerMessageAck = {
121
+ id: message.id,
122
+ sequence: message.sequence,
123
+ state: rejection.state,
124
+ reason: rejection.reason,
125
+ };
126
+ record.issued.set(message.id, ack);
127
+ return { ack, deliver: false };
128
+ }
129
+
130
+ // A sequence this origin ACTUALLY delivered before, under a different id: a sender reusing a
131
+ // sequence, which is a protocol error rather than a duplicate.
132
+ //
133
+ // Membership, not `<= highestSequence`. The high-water form refused the late arrival of a gap it
134
+ // had itself reported — deliver 1, receive 3 (gap at 2 reported), then 2 arrives and is rejected
135
+ // as "already used" although it was never delivered. That contradicted this module's own stated
136
+ // rule that a gap is reported so the SESSION layer can decide, since the session never got the
137
+ // chance. Found in review.
138
+ if (record.delivered.has(message.sequence)) {
139
+ const ack: IPeerMessageAck = {
140
+ id: message.id,
141
+ sequence: message.sequence,
142
+ state: 'refused',
143
+ reason:
144
+ `sequence ${message.sequence} was already delivered by this origin under a different ` +
145
+ 'message id. A retry must repeat its id; a new id on a delivered sequence cannot be ' +
146
+ 'ordered against what the receiver already has.',
147
+ };
148
+ record.issued.set(message.id, ack);
149
+ return { ack, deliver: false };
150
+ }
151
+
152
+ const expected = record.highestSequence + 1;
153
+ const gapBefore = message.sequence > expected ? expected : undefined;
154
+
155
+ const ack: IPeerMessageAck = {
156
+ id: message.id,
157
+ sequence: message.sequence,
158
+ state: 'delivered',
159
+ };
160
+ record.issued.set(message.id, ack);
161
+ record.delivered.add(message.sequence);
162
+ // The frontier only moves FORWARD. A gap arriving late fills membership without rewinding what a
163
+ // later gap is measured against.
164
+ record.highestSequence = Math.max(record.highestSequence, message.sequence);
165
+
166
+ return gapBefore === undefined ? { ack, deliver: true } : { ack, deliver: true, gapBefore };
167
+ }
168
+
169
+ /**
170
+ * Mark a delivered message as acknowledged by the agent side.
171
+ *
172
+ * Separate from `admitPeerMessage` because delivery and acknowledgement answer different questions —
173
+ * "did the receiver take it" versus "did the agent act on it" — and a sender waiting for the second
174
+ * must not be told the first.
175
+ */
176
+ export function acknowledgePeerMessage(
177
+ ledger: IPeerMessageLedger,
178
+ originSessionId: string,
179
+ id: string,
180
+ ): IPeerMessageAck | undefined {
181
+ const record = ledger.origins.get(originSessionId);
182
+ const issued = record?.issued.get(id);
183
+ if (record === undefined || issued === undefined || issued.state !== 'delivered')
184
+ return undefined;
185
+ const acknowledged: IPeerMessageAck = { ...issued, state: 'acknowledged' };
186
+ record.issued.set(id, acknowledged);
187
+ return acknowledged;
188
+ }
189
+
190
+ /** Forget one origin — used when a peer disconnects and its sequence space is no longer meaningful. */
191
+ export function forgetPeerOrigin(ledger: IPeerMessageLedger, originSessionId: string): void {
192
+ ledger.origins.delete(originSessionId);
193
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The one argument a person recognises a tool call by, for every transport that puts a permission
3
+ * request to a person in its own wire form. Shared so the transports name the same call the same
4
+ * way; the built-in file tools take `filePath`, other tools `file_path` or `path`.
5
+ */
6
+ import type { TToolArgs } from '@robota-sdk/agent-core';
7
+
8
+ /** In the order the tools put them first. */
9
+ const SUMMARY_ARGUMENTS = ['command', 'filePath', 'file_path', 'path', 'url', 'pattern', 'query'];
10
+
11
+ /** The call's identifying argument, or `undefined` when it has none worth showing. */
12
+ export function permissionSummaryArgument(toolArgs: TToolArgs): string | undefined {
13
+ for (const key of SUMMARY_ARGUMENTS) {
14
+ const value = toolArgs[key];
15
+ if (typeof value === 'string' && value !== '') return value;
16
+ }
17
+ return undefined;
18
+ }