@otto-code/client 0.8.12 → 0.8.14

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.
@@ -117,6 +117,7 @@ function toTimeoutError(error, label, timeoutMs) {
117
117
  const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
118
118
  const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
119
119
  const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60000;
120
+ const PUSH_TOKEN_REVOCATION_TIMEOUT_MS = 2000;
120
121
  const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
121
122
  const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
122
123
  const LIVENESS_HEARTBEAT_INTERVAL_MS = 10000;
@@ -185,6 +186,30 @@ function concatByteChunks(chunks, size) {
185
186
  }
186
187
  return bytes;
187
188
  }
189
+ function extractDispatchAgentId(message) {
190
+ if (message.type === "agent_stream") {
191
+ return message.payload.agentId;
192
+ }
193
+ if (message.type === "agent_update") {
194
+ return message.payload.kind === "upsert" ? message.payload.agent.id : message.payload.agentId;
195
+ }
196
+ return undefined;
197
+ }
198
+ function getTransportFrameSize(frame) {
199
+ if (typeof frame === "string") {
200
+ return frame.length;
201
+ }
202
+ return frame.byteLength;
203
+ }
204
+ function describeInboundTransportFrame(frame, rawBytes) {
205
+ if (typeof frame === "string") {
206
+ return { kind: "text", size: String(frame.length) };
207
+ }
208
+ if (rawBytes) {
209
+ return { kind: "binary", size: String(rawBytes.byteLength) };
210
+ }
211
+ return { kind: "unknown", size: "0" };
212
+ }
188
213
  function hashForLog(value) {
189
214
  let hash = 0;
190
215
  for (let index = 0; index < value.length; index += 1) {
@@ -595,6 +620,45 @@ export class DaemonClient {
595
620
  // ============================================================================
596
621
  // Core Send Helpers
597
622
  // ============================================================================
623
+ beginTraceSection(name, args) {
624
+ const trace = this.config.trace;
625
+ if (!trace?.isEnabled()) {
626
+ return false;
627
+ }
628
+ trace.beginSection(name, args);
629
+ return true;
630
+ }
631
+ endTraceSection(isOpen) {
632
+ if (isOpen) {
633
+ this.config.trace?.endSection();
634
+ }
635
+ }
636
+ traceInstant(name, args) {
637
+ const isOpen = this.beginTraceSection(name, args);
638
+ this.endTraceSection(isOpen);
639
+ }
640
+ sendJsonMessage(envelopeType, messageType, message) {
641
+ this.traceInstant("otto.ws.message.outbound", {
642
+ envelopeType,
643
+ messageType,
644
+ });
645
+ this.sendTransportFrame(JSON.stringify(message));
646
+ }
647
+ sendTransportFrame(frame) {
648
+ if (!this.transport) {
649
+ throw new Error("Transport not connected");
650
+ }
651
+ const isOpen = this.beginTraceSection("otto.ws.frame.outbound", {
652
+ kind: typeof frame === "string" ? "text" : "binary",
653
+ size: String(getTransportFrameSize(frame)),
654
+ });
655
+ try {
656
+ this.transport.send(frame);
657
+ }
658
+ finally {
659
+ this.endTraceSection(isOpen);
660
+ }
661
+ }
598
662
  /**
599
663
  * Send a session message. For fire-and-forget messages (heartbeats, etc.),
600
664
  * failures are suppressed if `suppressSendErrors` is configured.
@@ -609,7 +673,7 @@ export class DaemonClient {
609
673
  }
610
674
  const payload = SessionInboundMessageSchema.parse(message);
611
675
  try {
612
- this.transport.send(JSON.stringify({ type: "session", message: payload }));
676
+ this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
613
677
  }
614
678
  catch (error) {
615
679
  if (this.config.suppressSendErrors) {
@@ -626,7 +690,11 @@ export class DaemonClient {
626
690
  throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
627
691
  }
628
692
  try {
629
- this.transport.send(frame);
693
+ this.traceInstant("otto.ws.message.outbound", {
694
+ envelopeType: "binary",
695
+ messageType: "binary",
696
+ });
697
+ this.sendTransportFrame(frame);
630
698
  }
631
699
  catch (error) {
632
700
  if (this.config.suppressSendErrors) {
@@ -646,7 +714,7 @@ export class DaemonClient {
646
714
  // If connected, send immediately
647
715
  if (this.transport && status === "connected") {
648
716
  const payload = SessionInboundMessageSchema.parse(message);
649
- this.transport.send(JSON.stringify({ type: "session", message: payload }));
717
+ this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
650
718
  return Promise.resolve();
651
719
  }
652
720
  // If connecting, queue the message to be sent once connected
@@ -677,7 +745,7 @@ export class DaemonClient {
677
745
  try {
678
746
  if (this.transport && this.connectionState.status === "connected") {
679
747
  const payload = SessionInboundMessageSchema.parse(pending.message);
680
- this.transport.send(JSON.stringify({ type: "session", message: payload }));
748
+ this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
681
749
  pending.resolve();
682
750
  }
683
751
  else {
@@ -785,7 +853,7 @@ export class DaemonClient {
785
853
  }
786
854
  const payload = SessionInboundMessageSchema.parse(message);
787
855
  try {
788
- this.transport.send(JSON.stringify({ type: "session", message: payload }));
856
+ this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
789
857
  }
790
858
  catch (error) {
791
859
  throw error instanceof Error ? error : new Error(String(error));
@@ -855,6 +923,15 @@ export class DaemonClient {
855
923
  token,
856
924
  });
857
925
  }
926
+ async unregisterPushToken(token) {
927
+ const requestId = this.createRequestId();
928
+ await this.sendCorrelatedSessionRequest({
929
+ requestId,
930
+ message: { type: "push.unregister.request", token, requestId },
931
+ responseType: "push.unregister.response",
932
+ timeout: PUSH_TOKEN_REVOCATION_TIMEOUT_MS,
933
+ });
934
+ }
858
935
  async ping(params) {
859
936
  const requestId = params?.requestId ?? `ping-${Date.now()}-${Math.random().toString(36).slice(2)}`;
860
937
  const clientSentAt = Date.now();
@@ -934,7 +1011,7 @@ export class DaemonClient {
934
1011
  };
935
1012
  this.pingProbe = probe;
936
1013
  try {
937
- this.transport.send(JSON.stringify({ type: "ping" }));
1014
+ this.sendJsonMessage("ping", "ping", { type: "ping" });
938
1015
  }
939
1016
  catch (error) {
940
1017
  this.clearPingProbe();
@@ -1007,6 +1084,7 @@ export class DaemonClient {
1007
1084
  type: "fetch_agent_history_request",
1008
1085
  requestId: resolvedRequestId,
1009
1086
  ...(options?.filter ? { filter: options.filter } : {}),
1087
+ ...(options?.search ? { search: options.search } : {}),
1010
1088
  ...(options?.sort ? { sort: options.sort } : {}),
1011
1089
  ...(options?.page ? { page: options.page } : {}),
1012
1090
  });
@@ -1778,6 +1856,14 @@ export class DaemonClient {
1778
1856
  }
1779
1857
  return { target: payload.target };
1780
1858
  }
1859
+ async setProjectIcon(projectId, source, requestId) {
1860
+ const payload = await this.sendNamespacedCorrelatedSessionRequest({
1861
+ requestId,
1862
+ message: { type: "project.icon.set.request", projectId, source },
1863
+ });
1864
+ if (!payload.accepted)
1865
+ throw new Error(payload.error ?? "setProjectIcon rejected");
1866
+ }
1781
1867
  async removeProject(projectId, requestId) {
1782
1868
  const payload = await this.sendNamespacedCorrelatedSessionRequest({
1783
1869
  requestId,
@@ -2262,6 +2348,7 @@ export class DaemonClient {
2262
2348
  ...(options.cursor ? { cursor: options.cursor } : {}),
2263
2349
  ...(typeof options.limit === "number" ? { limit: options.limit } : {}),
2264
2350
  ...(options.projection ? { projection: options.projection } : {}),
2351
+ ...(options.mergeWindow === true ? { mergeWindow: true } : {}),
2265
2352
  });
2266
2353
  const payload = await this.sendRequest({
2267
2354
  requestId: resolvedRequestId,
@@ -2283,6 +2370,28 @@ export class DaemonClient {
2283
2370
  }
2284
2371
  return payload;
2285
2372
  }
2373
+ async listAgentTimelinePrompts(agentId, options = {}) {
2374
+ const requestId = this.createRequestId(options.requestId);
2375
+ const message = SessionInboundMessageSchema.parse({
2376
+ type: "agent.timeline.list_prompts.request",
2377
+ agentId,
2378
+ requestId,
2379
+ });
2380
+ const payload = await this.sendRequest({
2381
+ requestId,
2382
+ message,
2383
+ timeout: options.timeout,
2384
+ options: { skipQueue: true },
2385
+ select: (response) => response.type === "agent.timeline.list_prompts.response" &&
2386
+ response.payload.requestId === requestId
2387
+ ? response.payload
2388
+ : null,
2389
+ });
2390
+ if (payload.error) {
2391
+ throw new Error(payload.error);
2392
+ }
2393
+ return payload;
2394
+ }
2286
2395
  async buildAgentForkContext(agentId, options = {}) {
2287
2396
  const resolvedRequestId = this.createRequestId(options.requestId);
2288
2397
  const message = SessionInboundMessageSchema.parse({
@@ -2647,6 +2756,40 @@ export class DaemonClient {
2647
2756
  }
2648
2757
  return payload.notice ?? null;
2649
2758
  }
2759
+ /**
2760
+ * Applies a whole agent-config bundle in one request. Use this instead of
2761
+ * chaining the single-field setters when the values belong together so client
2762
+ * interruption and other mutations cannot interleave between steps. A
2763
+ * provider rejection can still leave earlier steps applied.
2764
+ * Gated on `server_info.features.agentConfigApply`.
2765
+ */
2766
+ async applyAgentConfig(agentId, config) {
2767
+ const requestId = this.createRequestId();
2768
+ const message = SessionInboundMessageSchema.parse({
2769
+ type: "agent.config.apply.request",
2770
+ agentId,
2771
+ config,
2772
+ requestId,
2773
+ });
2774
+ const payload = await this.sendRequest({
2775
+ requestId,
2776
+ message,
2777
+ options: { skipQueue: true },
2778
+ select: (msg) => {
2779
+ if (msg.type !== "agent.config.apply.response") {
2780
+ return null;
2781
+ }
2782
+ if (msg.payload.requestId !== requestId) {
2783
+ return null;
2784
+ }
2785
+ return msg.payload;
2786
+ },
2787
+ });
2788
+ if (!payload.accepted) {
2789
+ throw new Error(payload.error ?? "applyAgentConfig rejected");
2790
+ }
2791
+ return payload.notice ?? null;
2792
+ }
2650
2793
  async restartServer(reason, requestId) {
2651
2794
  const resolvedRequestId = this.createRequestId(requestId);
2652
2795
  const message = SessionInboundMessageSchema.parse({
@@ -3712,48 +3855,6 @@ export class DaemonClient {
3712
3855
  requestId: input.requestId,
3713
3856
  }));
3714
3857
  }
3715
- /** Create an empty file or a directory. Never overwrites - see FileCreateResultSchema. */
3716
- async createFileEntry(options) {
3717
- const payload = await this.sendCorrelatedSessionRequest({
3718
- requestId: options.requestId,
3719
- message: {
3720
- type: "file.create.request",
3721
- cwd: options.cwd,
3722
- path: options.path,
3723
- kind: options.kind,
3724
- },
3725
- responseType: "file.create.response",
3726
- });
3727
- return payload.result;
3728
- }
3729
- /** Permanent delete - an unlink, not a move to any trash. */
3730
- async deleteFileEntry(options) {
3731
- const payload = await this.sendCorrelatedSessionRequest({
3732
- requestId: options.requestId,
3733
- message: {
3734
- type: "file.delete.request",
3735
- cwd: options.cwd,
3736
- path: options.path,
3737
- recursive: options.recursive,
3738
- },
3739
- responseType: "file.delete.response",
3740
- });
3741
- return payload.result;
3742
- }
3743
- /** Rename, which is also move. Never clobbers an occupied destination. */
3744
- async renameFileEntry(options) {
3745
- const payload = await this.sendCorrelatedSessionRequest({
3746
- requestId: options.requestId,
3747
- message: {
3748
- type: "file.rename.request",
3749
- cwd: options.cwd,
3750
- path: options.path,
3751
- newPath: options.newPath,
3752
- },
3753
- responseType: "file.rename.response",
3754
- });
3755
- return payload.result;
3756
- }
3757
3858
  async refineFile(options) {
3758
3859
  const payload = await this.sendCorrelatedSessionRequest({
3759
3860
  requestId: options.requestId,
@@ -4446,6 +4547,7 @@ export class DaemonClient {
4446
4547
  message: {
4447
4548
  type: "get_providers_snapshot_request",
4448
4549
  cwd: options?.cwd,
4550
+ ifNoneMatch: options?.ifNoneMatch,
4449
4551
  },
4450
4552
  responseType: "get_providers_snapshot_response",
4451
4553
  });
@@ -4978,6 +5080,31 @@ export class DaemonClient {
4978
5080
  });
4979
5081
  return unwrapBrainJob(payload);
4980
5082
  }
5083
+ async createFileEntry(input) {
5084
+ return this.sendNamespacedCorrelatedSessionRequest({
5085
+ message: { type: "fs.entry.create.request", ...input },
5086
+ });
5087
+ }
5088
+ async renameFileEntry(input) {
5089
+ return this.sendNamespacedCorrelatedSessionRequest({
5090
+ message: { type: "fs.entry.rename.request", ...input },
5091
+ });
5092
+ }
5093
+ async duplicateFileEntry(input) {
5094
+ return this.sendNamespacedCorrelatedSessionRequest({
5095
+ message: { type: "fs.entry.duplicate.request", ...input },
5096
+ });
5097
+ }
5098
+ async deleteFileEntry(input) {
5099
+ return this.sendNamespacedCorrelatedSessionRequest({
5100
+ message: { type: "fs.entry.delete.request", ...input },
5101
+ });
5102
+ }
5103
+ async checkoutDiscardChanges(cwd, input) {
5104
+ return this.sendNamespacedCorrelatedSessionRequest({
5105
+ message: { type: "checkout.discard_changes.request", cwd, paths: input.paths },
5106
+ });
5107
+ }
4981
5108
  async brainJobsList(requestId) {
4982
5109
  const payload = await this.sendCorrelatedSessionRequest({
4983
5110
  requestId,
@@ -5093,6 +5220,15 @@ export class DaemonClient {
5093
5220
  }
5094
5221
  return payload.status;
5095
5222
  }
5223
+ async getProjectIcon(projectId, requestId) {
5224
+ return this.sendNamespacedCorrelatedSessionRequest({
5225
+ requestId,
5226
+ message: { type: "project.icon.get.request", projectId },
5227
+ });
5228
+ }
5229
+ // ============================================================================
5230
+ // Provider Models / Commands
5231
+ // ============================================================================
5096
5232
  /** Delete a model's files. The brain refuses while that model is loaded. */
5097
5233
  async brainModelDelete(modelId, requestId) {
5098
5234
  const payload = await this.sendCorrelatedSessionRequest({
@@ -5689,86 +5825,6 @@ export class DaemonClient {
5689
5825
  responseType: "terminal.compatibility.diagnostic.response",
5690
5826
  });
5691
5827
  }
5692
- async createChatRoom(options) {
5693
- return this.sendCorrelatedSessionRequest({
5694
- requestId: options.requestId,
5695
- message: {
5696
- type: "chat/create",
5697
- name: options.name,
5698
- ...(options.purpose ? { purpose: options.purpose } : {}),
5699
- },
5700
- responseType: "chat/create/response",
5701
- });
5702
- }
5703
- async listChatRooms(requestId) {
5704
- return this.sendCorrelatedSessionRequest({
5705
- requestId,
5706
- message: {
5707
- type: "chat/list",
5708
- },
5709
- responseType: "chat/list/response",
5710
- });
5711
- }
5712
- async inspectChatRoom(options) {
5713
- return this.sendCorrelatedSessionRequest({
5714
- requestId: options.requestId,
5715
- message: {
5716
- type: "chat/inspect",
5717
- room: options.room,
5718
- },
5719
- responseType: "chat/inspect/response",
5720
- });
5721
- }
5722
- async deleteChatRoom(options) {
5723
- return this.sendCorrelatedSessionRequest({
5724
- requestId: options.requestId,
5725
- message: {
5726
- type: "chat/delete",
5727
- room: options.room,
5728
- },
5729
- responseType: "chat/delete/response",
5730
- });
5731
- }
5732
- async postChatMessage(options) {
5733
- return this.sendCorrelatedSessionRequest({
5734
- requestId: options.requestId,
5735
- message: {
5736
- type: "chat/post",
5737
- room: options.room,
5738
- body: options.body,
5739
- ...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
5740
- ...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
5741
- },
5742
- responseType: "chat/post/response",
5743
- });
5744
- }
5745
- async readChatMessages(options) {
5746
- return this.sendCorrelatedSessionRequest({
5747
- requestId: options.requestId,
5748
- message: {
5749
- type: "chat/read",
5750
- room: options.room,
5751
- ...(typeof options.limit === "number" ? { limit: options.limit } : {}),
5752
- ...(options.since ? { since: options.since } : {}),
5753
- ...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
5754
- },
5755
- responseType: "chat/read/response",
5756
- timeout: options.timeout,
5757
- });
5758
- }
5759
- async waitForChatMessages(options) {
5760
- return this.sendCorrelatedSessionRequest({
5761
- requestId: options.requestId,
5762
- message: {
5763
- type: "chat/wait",
5764
- room: options.room,
5765
- ...(options.afterMessageId ? { afterMessageId: options.afterMessageId } : {}),
5766
- ...(typeof options.timeoutMs === "number" ? { timeoutMs: options.timeoutMs } : {}),
5767
- },
5768
- responseType: "chat/wait/response",
5769
- timeout: (options.timeoutMs ?? 0) + 10000,
5770
- });
5771
- }
5772
5828
  async scheduleCreate(options) {
5773
5829
  return this.sendCorrelatedSessionRequest({
5774
5830
  requestId: options.requestId,
@@ -5968,76 +6024,6 @@ export class DaemonClient {
5968
6024
  responseType: "artifact.get-content.response",
5969
6025
  });
5970
6026
  }
5971
- async loopRun(options) {
5972
- return this.sendCorrelatedSessionRequest({
5973
- requestId: options.requestId,
5974
- message: {
5975
- type: "loop/run",
5976
- prompt: options.prompt,
5977
- cwd: options.cwd,
5978
- ...(options.provider ? { provider: options.provider } : {}),
5979
- ...(options.model ? { model: options.model } : {}),
5980
- ...(options.modeId ? { modeId: options.modeId } : {}),
5981
- ...(options.verifierProvider ? { verifierProvider: options.verifierProvider } : {}),
5982
- ...(options.verifierModel ? { verifierModel: options.verifierModel } : {}),
5983
- ...(options.verifierModeId ? { verifierModeId: options.verifierModeId } : {}),
5984
- ...(options.verifyPrompt ? { verifyPrompt: options.verifyPrompt } : {}),
5985
- ...(options.verifyChecks && options.verifyChecks.length > 0
5986
- ? { verifyChecks: options.verifyChecks }
5987
- : {}),
5988
- ...(options.name ? { name: options.name } : {}),
5989
- ...(typeof options.sleepMs === "number" ? { sleepMs: options.sleepMs } : {}),
5990
- ...(typeof options.maxIterations === "number"
5991
- ? { maxIterations: options.maxIterations }
5992
- : {}),
5993
- ...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}),
5994
- },
5995
- responseType: "loop/run/response",
5996
- });
5997
- }
5998
- async loopList(requestId) {
5999
- return this.sendCorrelatedSessionRequest({
6000
- requestId,
6001
- message: {
6002
- type: "loop/list",
6003
- },
6004
- responseType: "loop/list/response",
6005
- });
6006
- }
6007
- async loopInspect(options) {
6008
- const normalized = typeof options === "string" ? { id: options } : options;
6009
- return this.sendCorrelatedSessionRequest({
6010
- requestId: normalized.requestId,
6011
- message: {
6012
- type: "loop/inspect",
6013
- id: normalized.id,
6014
- },
6015
- responseType: "loop/inspect/response",
6016
- });
6017
- }
6018
- async loopLogs(options, afterSeq) {
6019
- const normalized = typeof options === "string" ? { id: options, afterSeq } : options;
6020
- return this.sendCorrelatedSessionRequest({
6021
- requestId: normalized.requestId,
6022
- message: {
6023
- type: "loop/logs",
6024
- id: normalized.id,
6025
- ...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}),
6026
- },
6027
- responseType: "loop/logs/response",
6028
- });
6029
- }
6030
- async loopStop(options) {
6031
- const normalized = typeof options === "string" ? { id: options } : options;
6032
- return this.sendCorrelatedSessionRequest({
6033
- requestId: normalized.requestId,
6034
- message: {
6035
- type: "loop/stop",
6036
- id: normalized.id,
6037
- },
6038
- responseType: "loop/stop/response",
6039
- });
6040
- }
6041
6027
  onTerminalStreamEvent(handler) {
6042
6028
  return this.terminalStreams.onEvent(handler);
6043
6029
  }
@@ -6078,6 +6064,14 @@ export class DaemonClient {
6078
6064
  getTrafficHotspots(limit) {
6079
6065
  return this.runtimeMetrics?.getTrafficHotspots(limit) ?? [];
6080
6066
  }
6067
+ /**
6068
+ * Bounded, timestamped dispatch phases for matching an inbound daemon
6069
+ * message to a browser Long Animation Frame. This is diagnostic evidence,
6070
+ * not a protocol surface.
6071
+ */
6072
+ getInboundDispatchTimings(sinceMs) {
6073
+ return this.runtimeMetrics?.getInboundDispatchTimings(sinceMs) ?? [];
6074
+ }
6081
6075
  resolveTransportUrlForAttempt() {
6082
6076
  return this.config.url;
6083
6077
  }
@@ -6091,7 +6085,7 @@ export class DaemonClient {
6091
6085
  return;
6092
6086
  }
6093
6087
  try {
6094
- this.transport.send(JSON.stringify({
6088
+ this.sendJsonMessage("hello", "hello", {
6095
6089
  type: "hello",
6096
6090
  clientId: this.config.clientId,
6097
6091
  clientType: this.config.clientType ?? "cli",
@@ -6105,10 +6099,11 @@ export class DaemonClient {
6105
6099
  // so dropping it silently kills cross-session project renames.
6106
6100
  [CLIENT_CAPS.projectUpdates]: true,
6107
6101
  [CLIENT_CAPS.communicationsPresenceUpdates]: true,
6102
+ [CLIENT_CAPS.compactProviderSnapshots]: true,
6108
6103
  ...this.config.capabilities,
6109
6104
  },
6110
6105
  ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
6111
- }));
6106
+ });
6112
6107
  }
6113
6108
  catch (error) {
6114
6109
  const message = error instanceof Error ? error.message : "Failed to send hello message";
@@ -6173,25 +6168,38 @@ export class DaemonClient {
6173
6168
  return;
6174
6169
  }
6175
6170
  const rawBytes = asUint8Array(rawData);
6176
- if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
6177
- return;
6171
+ const isOpen = this.beginTraceSection("otto.ws.frame.inbound", describeInboundTransportFrame(rawData, rawBytes));
6172
+ try {
6173
+ if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
6174
+ return;
6175
+ }
6176
+ const payload = decodeMessageData(rawData);
6177
+ if (!payload) {
6178
+ return;
6179
+ }
6180
+ this.handleJsonPayload(payload, rawBytes?.byteLength);
6178
6181
  }
6179
- const payload = decodeMessageData(rawData);
6180
- if (!payload) {
6181
- return;
6182
+ finally {
6183
+ this.endTraceSection(isOpen);
6182
6184
  }
6183
- this.handleJsonPayload(payload, rawBytes?.byteLength);
6184
6185
  }
6185
6186
  handleJsonPayload(payload, rawBytesLength) {
6186
6187
  const bytes = rawBytesLength ?? payload.length;
6188
+ const dispatchAt = Date.now();
6187
6189
  const startMs = perfNow();
6188
6190
  let parsedJson;
6191
+ const parseTraceOpen = this.beginTraceSection("otto.ws.json.parse", {
6192
+ size: String(bytes),
6193
+ });
6189
6194
  try {
6190
6195
  parsedJson = JSON.parse(payload);
6191
6196
  }
6192
6197
  catch {
6193
6198
  return;
6194
6199
  }
6200
+ finally {
6201
+ this.endTraceSection(parseTraceOpen);
6202
+ }
6195
6203
  const parsed = validateWSOutboundMessage(parsedJson);
6196
6204
  if (!parsed.success) {
6197
6205
  const responseIdentity = extractCorrelatedResponseIdentity(parsedJson);
@@ -6210,13 +6218,33 @@ export class DaemonClient {
6210
6218
  }
6211
6219
  this.consecutiveLivenessFailures = 0;
6212
6220
  if (parsed.data.type === "pong") {
6221
+ this.traceInstant("otto.ws.message.inbound", {
6222
+ envelopeType: "pong",
6223
+ messageType: "pong",
6224
+ });
6213
6225
  this.resolvePingProbe();
6214
6226
  this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
6215
6227
  return;
6216
6228
  }
6217
- this.handleSessionMessage(parsed.data.message);
6229
+ this.traceInstant("otto.ws.message.inbound", {
6230
+ envelopeType: "session",
6231
+ messageType: parsed.data.message.type,
6232
+ });
6233
+ const phases = this.handleSessionMessage(parsed.data.message);
6218
6234
  const msgType = parsed.data.message.type;
6219
- this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs);
6235
+ const totalMs = perfNow() - startMs;
6236
+ this.runtimeMetrics?.recordMessage(msgType, bytes, totalMs);
6237
+ this.runtimeMetrics?.recordInboundDispatch({
6238
+ at: dispatchAt,
6239
+ type: msgType,
6240
+ agentId: extractDispatchAgentId(parsed.data.message),
6241
+ bytes,
6242
+ decodeAndValidateMs: phases.startedAtMs - startMs,
6243
+ internalDispatchMs: phases.internalDispatchMs,
6244
+ rawListenersMs: phases.rawListenersMs,
6245
+ typedHandlersMs: phases.typedHandlersMs,
6246
+ totalMs,
6247
+ });
6220
6248
  if (parsed.data.message.type === "agent_stream") {
6221
6249
  this.runtimeMetrics?.recordAgentStream(parsed.data.message.payload);
6222
6250
  }
@@ -6224,6 +6252,11 @@ export class DaemonClient {
6224
6252
  tryHandleBinaryFrame(rawBytes) {
6225
6253
  const fileFrame = decodeFileTransferFrame(rawBytes);
6226
6254
  if (fileFrame) {
6255
+ this.traceInstant("otto.ws.message.inbound", {
6256
+ envelopeType: "binary",
6257
+ messageType: "file",
6258
+ opcode: String(fileFrame.opcode),
6259
+ });
6227
6260
  this.consecutiveLivenessFailures = 0;
6228
6261
  this.handleFileTransferFrame(fileFrame);
6229
6262
  this.runtimeMetrics?.recordBinaryFrame("other", rawBytes.byteLength, 0);
@@ -6233,6 +6266,11 @@ export class DaemonClient {
6233
6266
  if (!frame) {
6234
6267
  return false;
6235
6268
  }
6269
+ this.traceInstant("otto.ws.message.inbound", {
6270
+ envelopeType: "binary",
6271
+ messageType: "terminal",
6272
+ opcode: String(frame.opcode),
6273
+ });
6236
6274
  this.consecutiveLivenessFailures = 0;
6237
6275
  const binaryStartMs = perfNow();
6238
6276
  this.terminalStreams.handleFrame(frame);
@@ -6418,6 +6456,7 @@ export class DaemonClient {
6418
6456
  });
6419
6457
  }
6420
6458
  handleSessionMessage(msg) {
6459
+ const startedAtMs = perfNow();
6421
6460
  const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg);
6422
6461
  if (consumerMessage.type === "status") {
6423
6462
  const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
@@ -6445,6 +6484,7 @@ export class DaemonClient {
6445
6484
  if (consumerMessage.type === "project.scaffold.progress") {
6446
6485
  this.scaffoldProgressListeners.get(consumerMessage.payload.requestId)?.(consumerMessage.payload);
6447
6486
  }
6487
+ const rawListenersStartedAtMs = perfNow();
6448
6488
  if (this.rawMessageListeners.size > 0) {
6449
6489
  for (const handler of this.rawMessageListeners) {
6450
6490
  try {
@@ -6455,6 +6495,7 @@ export class DaemonClient {
6455
6495
  }
6456
6496
  }
6457
6497
  }
6498
+ const typedHandlersStartedAtMs = perfNow();
6458
6499
  const handlers = this.messageHandlers.get(consumerMessage.type);
6459
6500
  if (handlers) {
6460
6501
  for (const handler of handlers) {
@@ -6473,6 +6514,13 @@ export class DaemonClient {
6473
6514
  }
6474
6515
  }
6475
6516
  this.resolveWaiters(consumerMessage);
6517
+ const finishedAtMs = perfNow();
6518
+ return {
6519
+ startedAtMs,
6520
+ internalDispatchMs: rawListenersStartedAtMs - startedAtMs,
6521
+ rawListenersMs: typedHandlersStartedAtMs - rawListenersStartedAtMs,
6522
+ typedHandlersMs: finishedAtMs - typedHandlersStartedAtMs,
6523
+ };
6476
6524
  }
6477
6525
  resolveWaiters(msg) {
6478
6526
  for (const waiter of Array.from(this.waiters)) {