@otto-code/client 0.7.4 → 0.7.6

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.
@@ -57,6 +57,16 @@ class DaemonRpcError extends Error {
57
57
  this.code = params.code;
58
58
  }
59
59
  }
60
+ class DaemonProtocolError extends Error {
61
+ constructor(identity) {
62
+ const responseLabel = identity.responseType ?? "unknown response";
63
+ super(`Response validation failed for ${responseLabel}`);
64
+ this.code = "invalid_response";
65
+ this.name = "DaemonProtocolError";
66
+ this.requestId = identity.requestId;
67
+ this.responseType = identity.responseType;
68
+ }
69
+ }
60
70
  class PingTimeoutError extends Error {
61
71
  constructor(timeoutMs) {
62
72
  super(`Ping timed out (${timeoutMs}ms)`);
@@ -64,6 +74,40 @@ class PingTimeoutError extends Error {
64
74
  this.name = "PingTimeoutError";
65
75
  }
66
76
  }
77
+ /**
78
+ * Pull the request correlation out of a frame that failed schema validation.
79
+ * Reads only the envelope and `payload.requestId`, which is exactly the part a
80
+ * malformed response still gets right, so the waiting caller can be failed with
81
+ * a real reason rather than left to time out.
82
+ */
83
+ function extractCorrelatedResponseIdentity(input) {
84
+ if (!input || typeof input !== "object") {
85
+ return null;
86
+ }
87
+ const envelope = input;
88
+ if (envelope.type !== "session" || !envelope.message || typeof envelope.message !== "object") {
89
+ return null;
90
+ }
91
+ const message = envelope.message;
92
+ if (typeof message.type !== "string" ||
93
+ !(message.type === "rpc_error" ||
94
+ message.type.endsWith("_response") ||
95
+ message.type.endsWith(".response") ||
96
+ message.type.endsWith("/response"))) {
97
+ return null;
98
+ }
99
+ if (!message.payload || typeof message.payload !== "object") {
100
+ return null;
101
+ }
102
+ const payload = message.payload;
103
+ if (typeof payload.requestId !== "string") {
104
+ return null;
105
+ }
106
+ return {
107
+ requestId: payload.requestId,
108
+ responseType: message.type,
109
+ };
110
+ }
67
111
  function toTimeoutError(error, label, timeoutMs) {
68
112
  if (error instanceof PingTimeoutError) {
69
113
  return new Error(`${label} timed out (${timeoutMs}ms)`);
@@ -120,6 +164,7 @@ function legacyExplorerFileToBytes(file) {
120
164
  kind: file.kind,
121
165
  modifiedAt: file.modifiedAt,
122
166
  eol: file.eol,
167
+ revision: file.revision,
123
168
  };
124
169
  }
125
170
  function binaryFileKind(mime, encoding) {
@@ -169,6 +214,20 @@ function toReasonCode(reason) {
169
214
  }
170
215
  return "unknown";
171
216
  }
217
+ // A job-starting brain RPC returns { job, error }; surface the error as a throw
218
+ // so callers get a plain Promise<BrainJob>.
219
+ function unwrapBrainJob(payload) {
220
+ if (payload.error) {
221
+ throw new Error(payload.error);
222
+ }
223
+ if (!payload.job) {
224
+ throw new Error("The brain did not start the operation.");
225
+ }
226
+ return payload.job;
227
+ }
228
+ // A repo clone can take minutes on a large history, so it gets its own budget
229
+ // rather than the default request timeout.
230
+ const PROJECT_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000;
172
231
  export class DaemonClient {
173
232
  constructor(config) {
174
233
  this.config = config;
@@ -192,6 +251,7 @@ export class DaemonClient {
192
251
  this.connectionState = { status: "idle" };
193
252
  this.checkoutDiffSubscriptions = new Map();
194
253
  this.terminalDirectorySubscriptions = new Map();
254
+ this.fileSubscriptions = new Map();
195
255
  this.terminalStreams = new TerminalStreamRouter();
196
256
  // requestId -> progress listener for an in-flight project.scaffold.request.
197
257
  // Entries are always removed in scaffoldProject's finally block.
@@ -659,7 +719,7 @@ export class DaemonClient {
659
719
  return null;
660
720
  }
661
721
  return { kind: "ok", value };
662
- }, timeout, params.options);
722
+ }, timeout, { ...params.options, requestId: params.requestId });
663
723
  try {
664
724
  await this.sendSessionMessageOrThrow(params.message);
665
725
  }
@@ -1265,6 +1325,7 @@ export class DaemonClient {
1265
1325
  ...(options.personality ? { personality: options.personality } : {}),
1266
1326
  ...(options.env ? { env: options.env } : {}),
1267
1327
  ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
1328
+ ...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}),
1268
1329
  ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
1269
1330
  ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
1270
1331
  ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
@@ -1842,6 +1903,266 @@ export class DaemonClient {
1842
1903
  },
1843
1904
  });
1844
1905
  }
1906
+ async listProviderSubagents(parentAgentId, options = {}) {
1907
+ const requestId = this.createRequestId(options.requestId);
1908
+ const message = SessionInboundMessageSchema.parse({
1909
+ type: "agent.provider_subagents.list.request",
1910
+ parentAgentId,
1911
+ requestId,
1912
+ });
1913
+ const payload = await this.sendRequest({
1914
+ requestId,
1915
+ message,
1916
+ timeout: options.timeout,
1917
+ options: { skipQueue: true },
1918
+ select: (response) => response.type === "agent.provider_subagents.list.response" &&
1919
+ response.payload.requestId === requestId
1920
+ ? response.payload
1921
+ : null,
1922
+ });
1923
+ if (payload.error) {
1924
+ throw new Error(payload.error);
1925
+ }
1926
+ return payload;
1927
+ }
1928
+ async fetchProviderSubagentTimeline(parentAgentId, subagentId, options = {}) {
1929
+ const requestId = this.createRequestId(options.requestId);
1930
+ const message = SessionInboundMessageSchema.parse({
1931
+ type: "agent.provider_subagents.timeline.get.request",
1932
+ parentAgentId,
1933
+ subagentId,
1934
+ requestId,
1935
+ ...(options.direction ? { direction: options.direction } : {}),
1936
+ ...(options.cursor ? { cursor: options.cursor } : {}),
1937
+ ...(typeof options.limit === "number" ? { limit: options.limit } : {}),
1938
+ });
1939
+ const payload = await this.sendRequest({
1940
+ requestId,
1941
+ message,
1942
+ timeout: options.timeout,
1943
+ options: { skipQueue: true },
1944
+ select: (response) => response.type === "agent.provider_subagents.timeline.get.response" &&
1945
+ response.payload.requestId === requestId
1946
+ ? response.payload
1947
+ : null,
1948
+ });
1949
+ if (payload.error) {
1950
+ throw new Error(payload.error);
1951
+ }
1952
+ return payload;
1953
+ }
1954
+ async checkoutForgeGetCheckDetails(input, requestId) {
1955
+ return this.sendNamespacedCorrelatedSessionRequest({
1956
+ requestId,
1957
+ message: {
1958
+ type: "checkout.forge.get_check_details.request",
1959
+ cwd: input.cwd,
1960
+ repoOwner: input.repoOwner,
1961
+ repoName: input.repoName,
1962
+ checkRunId: input.checkRunId,
1963
+ workflowRunId: input.workflowRunId,
1964
+ changeRequestNumber: input.changeRequestNumber,
1965
+ },
1966
+ timeout: 60000,
1967
+ });
1968
+ }
1969
+ async checkoutForgeSetAutoMerge(cwd, input, requestId) {
1970
+ return this.sendNamespacedCorrelatedSessionRequest({
1971
+ requestId,
1972
+ message: {
1973
+ type: "checkout.forge.set_auto_merge.request",
1974
+ cwd,
1975
+ enabled: input.enabled,
1976
+ ...(input.enabled ? { mergeMethod: input.method } : {}),
1977
+ },
1978
+ timeout: 60000,
1979
+ });
1980
+ }
1981
+ async createProjectDirectory(input, requestId) {
1982
+ return this.sendNamespacedCorrelatedSessionRequest({
1983
+ requestId,
1984
+ message: {
1985
+ type: "project.create_directory.request",
1986
+ parentPath: input.parentPath,
1987
+ name: input.name,
1988
+ },
1989
+ });
1990
+ }
1991
+ async getCommitFileDiff(cwd, sha, path, requestId) {
1992
+ const payload = await this.sendNamespacedCorrelatedSessionRequest({
1993
+ requestId,
1994
+ message: {
1995
+ type: "checkout.commits.file_diff.request",
1996
+ cwd,
1997
+ sha,
1998
+ path,
1999
+ },
2000
+ timeout: 60000,
2001
+ });
2002
+ if (payload.error) {
2003
+ throw new Error(payload.error.message);
2004
+ }
2005
+ return { file: payload.file };
2006
+ }
2007
+ async inspectWorkspaceRecovery(workspaceId, requestId) {
2008
+ const payload = await this.sendNamespacedCorrelatedSessionRequest({
2009
+ requestId,
2010
+ message: {
2011
+ type: "workspace.recovery.inspect.request",
2012
+ workspaceId,
2013
+ },
2014
+ });
2015
+ return payload.state;
2016
+ }
2017
+ async listCheckoutCommits(cwd, requestId) {
2018
+ const payload = await this.sendNamespacedCorrelatedSessionRequest({
2019
+ requestId,
2020
+ message: {
2021
+ type: "checkout.commits.list.request",
2022
+ cwd,
2023
+ },
2024
+ timeout: 60000,
2025
+ });
2026
+ if (payload.error) {
2027
+ throw new Error(payload.error.message);
2028
+ }
2029
+ return { baseRef: payload.baseRef, commits: payload.commits };
2030
+ }
2031
+ async listProjects(requestId) {
2032
+ const resolvedRequestId = this.createRequestId(requestId);
2033
+ const message = SessionInboundMessageSchema.parse({
2034
+ type: "project.list.request",
2035
+ requestId: resolvedRequestId,
2036
+ });
2037
+ return this.sendRequest({
2038
+ requestId: resolvedRequestId,
2039
+ message,
2040
+ options: { skipQueue: true },
2041
+ select: (msg) => {
2042
+ if (msg.type !== "project.list.response")
2043
+ return null;
2044
+ if (msg.payload.requestId !== resolvedRequestId)
2045
+ return null;
2046
+ return msg.payload;
2047
+ },
2048
+ });
2049
+ }
2050
+ onAgentAttentionRequired(handler) {
2051
+ const unsubscribeLegacy = this.on("agent_stream", (message) => {
2052
+ if (message.payload.event.type !== "attention_required") {
2053
+ return;
2054
+ }
2055
+ const event = message.payload.event;
2056
+ handler({
2057
+ agentId: message.payload.agentId,
2058
+ reason: event.reason,
2059
+ timestamp: event.timestamp,
2060
+ shouldNotify: event.shouldNotify,
2061
+ ...(event.notification ? { notification: event.notification } : {}),
2062
+ });
2063
+ });
2064
+ const unsubscribeDedicated = this.on("agent_attention_required", (message) => {
2065
+ handler(message.payload);
2066
+ });
2067
+ return () => {
2068
+ unsubscribeLegacy();
2069
+ unsubscribeDedicated();
2070
+ };
2071
+ }
2072
+ async restoreWorkspace(workspaceId, requestId) {
2073
+ const payload = await this.sendNamespacedCorrelatedSessionRequest({
2074
+ requestId,
2075
+ message: {
2076
+ type: "workspace.recovery.restore.request",
2077
+ workspaceId,
2078
+ },
2079
+ timeout: 150000,
2080
+ });
2081
+ if (!payload.accepted) {
2082
+ throw new Error(payload.error ?? "Workspace recovery was rejected by the host");
2083
+ }
2084
+ }
2085
+ async searchGithubRepositories(input, requestId) {
2086
+ return this.sendNamespacedCorrelatedSessionRequest({
2087
+ requestId,
2088
+ message: {
2089
+ type: "workspace.github.search_repositories.request",
2090
+ query: input.query,
2091
+ limit: input.limit,
2092
+ },
2093
+ });
2094
+ }
2095
+ async setAgentTimelineSubscription(agentIds) {
2096
+ // COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their
2097
+ // legacy global stream and do not understand this RPC. Remove after
2098
+ // 2027-01-12 once the supported daemon floor is >= v0.1.106.
2099
+ if (!this.lastServerInfoMessage?.features?.selectiveAgentTimeline) {
2100
+ return;
2101
+ }
2102
+ const requestId = this.createRequestId();
2103
+ const normalizedAgentIds = [...new Set(agentIds)].sort();
2104
+ const message = SessionInboundMessageSchema.parse({
2105
+ type: "agent.timeline.set_subscription.request",
2106
+ agentIds: normalizedAgentIds,
2107
+ requestId,
2108
+ });
2109
+ await this.sendRequest({
2110
+ requestId,
2111
+ message,
2112
+ options: { skipQueue: true },
2113
+ select: (response) => {
2114
+ if (response.type !== "agent.timeline.set_subscription.response") {
2115
+ return null;
2116
+ }
2117
+ return response.payload.requestId === requestId ? response.payload : null;
2118
+ },
2119
+ });
2120
+ }
2121
+ async setWorkspacePinned(workspaceId, pinned, requestId) {
2122
+ const payload = await this.sendCorrelatedSessionRequest({
2123
+ requestId,
2124
+ message: {
2125
+ type: "workspace.pin.set.request",
2126
+ workspaceId,
2127
+ pinned,
2128
+ },
2129
+ responseType: "workspace.pin.set.response",
2130
+ });
2131
+ if (!payload.accepted) {
2132
+ throw new Error(payload.error ?? "setWorkspacePinned rejected");
2133
+ }
2134
+ return { pinnedAt: payload.pinnedAt };
2135
+ }
2136
+ async subscribeFile(input, onUpdate) {
2137
+ const subscriptionId = this.createRequestId();
2138
+ this.fileSubscriptions.set(subscriptionId, { ...input, onUpdate });
2139
+ try {
2140
+ const payload = await this.sendCorrelatedSessionRequest({
2141
+ message: {
2142
+ type: "fs.file.subscribe.request",
2143
+ cwd: input.cwd,
2144
+ path: input.path,
2145
+ subscriptionId,
2146
+ },
2147
+ responseType: "fs.file.subscribe.response",
2148
+ });
2149
+ return {
2150
+ initial: payload.initial,
2151
+ unsubscribe: () => {
2152
+ if (!this.fileSubscriptions.delete(subscriptionId))
2153
+ return;
2154
+ void this.sendCorrelatedSessionRequest({
2155
+ message: { type: "fs.file.unsubscribe.request", subscriptionId },
2156
+ responseType: "fs.file.unsubscribe.response",
2157
+ }).catch(() => undefined);
2158
+ },
2159
+ };
2160
+ }
2161
+ catch (error) {
2162
+ this.fileSubscriptions.delete(subscriptionId);
2163
+ throw error;
2164
+ }
2165
+ }
1845
2166
  async fetchAgentTimeline(agentId, options = {}) {
1846
2167
  const resolvedRequestId = this.createRequestId(options.requestId);
1847
2168
  const message = SessionInboundMessageSchema.parse({
@@ -1879,6 +2200,7 @@ export class DaemonClient {
1879
2200
  type: "agent.fork_context.request",
1880
2201
  agentId,
1881
2202
  requestId: resolvedRequestId,
2203
+ ...(options.boundaryCursor ? { boundaryCursor: options.boundaryCursor } : {}),
1882
2204
  ...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}),
1883
2205
  });
1884
2206
  const payload = await this.sendRequest({
@@ -2084,6 +2406,13 @@ export class DaemonClient {
2084
2406
  return msg.payload;
2085
2407
  },
2086
2408
  });
2409
+ // A refused cancellation comes back as an error payload, not a rejected
2410
+ // frame. Dropping it reported Stop as successful while the provider was
2411
+ // still running and still spending tokens — the exact outcome the daemon
2412
+ // refuses the cancel to avoid.
2413
+ if (payload.error) {
2414
+ throw new Error(payload.error);
2415
+ }
2087
2416
  // Absent ⇒ old daemon that doesn't report whether a run was interrupted.
2088
2417
  return payload.cancelled !== undefined ? { cancelled: payload.cancelled } : {};
2089
2418
  }
@@ -2139,6 +2468,7 @@ export class DaemonClient {
2139
2468
  if (!payload.accepted) {
2140
2469
  throw new Error(payload.error ?? "setAgentModel rejected");
2141
2470
  }
2471
+ return payload.notice ?? null;
2142
2472
  }
2143
2473
  async setAgentFeature(agentId, featureId, value) {
2144
2474
  const requestId = this.createRequestId();
@@ -3038,6 +3368,20 @@ export class DaemonClient {
3038
3368
  responseType: "branch_suggestions_response",
3039
3369
  });
3040
3370
  }
3371
+ async searchForge(options, requestId) {
3372
+ return this.sendCorrelatedSessionRequest({
3373
+ requestId,
3374
+ message: {
3375
+ type: "forge.search.request",
3376
+ cwd: options.cwd,
3377
+ query: options.query,
3378
+ limit: options.limit,
3379
+ kinds: options.kinds,
3380
+ },
3381
+ responseType: "forge.search.response",
3382
+ timeout: 15000,
3383
+ });
3384
+ }
3041
3385
  async searchGitHub(options, requestId) {
3042
3386
  return this.sendCorrelatedSessionRequest({
3043
3387
  requestId,
@@ -3172,6 +3516,18 @@ export class DaemonClient {
3172
3516
  hash: file.hash ?? null,
3173
3517
  };
3174
3518
  }
3519
+ /**
3520
+ * The fs.file.write RPC: optimistic-concurrency writes keyed by an opaque
3521
+ * revision. The hash-keyed `writeFile` below is kept for callers
3522
+ * that have not moved over.
3523
+ */
3524
+ async writeFsFile(input) {
3525
+ const payload = await this.sendCorrelatedSessionRequest({
3526
+ message: { type: "fs.file.write.request", ...input },
3527
+ responseType: "fs.file.write.response",
3528
+ });
3529
+ return payload.result;
3530
+ }
3175
3531
  /** Conditional save — see FileWriteRequestSchema for the no-clobber contract. */
3176
3532
  async writeFile(options) {
3177
3533
  const payload = await this.sendCorrelatedSessionRequest({
@@ -3190,6 +3546,73 @@ export class DaemonClient {
3190
3546
  });
3191
3547
  return payload.result;
3192
3548
  }
3549
+ /**
3550
+ * Write bytes to a workspace file — the path for generated artifacts the
3551
+ * text write cannot carry (it refuses binary targets outright). Gated on
3552
+ * `features.binaryFileWrite`; there is no client-side substitute, because
3553
+ * the client never touches a workspace file on any platform.
3554
+ *
3555
+ * Shaped like {@link uploadFile}: the JSON request says where the bytes go
3556
+ * and how many to expect, then the bytes follow as file-transfer frames
3557
+ * correlated on the same `requestId`. The daemon answers at FileEnd.
3558
+ */
3559
+ async writeBinaryFile(options) {
3560
+ const bytes = asUint8Array(options.bytes);
3561
+ if (!bytes) {
3562
+ throw new Error("File bytes are required.");
3563
+ }
3564
+ const resolvedRequestId = this.createRequestId(options.requestId);
3565
+ const responsePromise = this.sendCorrelatedSessionRequest({
3566
+ requestId: resolvedRequestId,
3567
+ message: {
3568
+ type: "fs.file.write_binary.request",
3569
+ cwd: options.cwd,
3570
+ path: options.path,
3571
+ size: bytes.byteLength,
3572
+ overwrite: options.overwrite,
3573
+ },
3574
+ responseType: "fs.file.write_binary.response",
3575
+ });
3576
+ this.sendFileTransfer({
3577
+ requestId: resolvedRequestId,
3578
+ bytes,
3579
+ // Nothing downstream reads the mime for a workspace write — the path
3580
+ // decides what the file is — but the frame metadata requires one.
3581
+ mime: "application/octet-stream",
3582
+ chunkSize: options.chunkSize,
3583
+ });
3584
+ const payload = await responsePromise;
3585
+ return payload.result;
3586
+ }
3587
+ /**
3588
+ * FileBegin, chunks, FileEnd. Synchronous through `sendBinaryFrame`, so the
3589
+ * frames leave in order and behind the JSON request that announced them.
3590
+ */
3591
+ sendFileTransfer(input) {
3592
+ this.sendBinaryFrame(encodeFileTransferFrame({
3593
+ opcode: FileTransferOpcode.FileBegin,
3594
+ requestId: input.requestId,
3595
+ metadata: {
3596
+ mime: input.mime,
3597
+ size: input.bytes.byteLength,
3598
+ encoding: "binary",
3599
+ modifiedAt: input.modifiedAt ?? new Date().toISOString(),
3600
+ ...(input.fileName ? { fileName: input.fileName } : {}),
3601
+ },
3602
+ }));
3603
+ const chunkSize = input.chunkSize ?? 1024 * 1024;
3604
+ for (let offset = 0; offset < input.bytes.byteLength; offset += chunkSize) {
3605
+ this.sendBinaryFrame(encodeFileTransferFrame({
3606
+ opcode: FileTransferOpcode.FileChunk,
3607
+ requestId: input.requestId,
3608
+ payload: input.bytes.subarray(offset, Math.min(offset + chunkSize, input.bytes.byteLength)),
3609
+ }));
3610
+ }
3611
+ this.sendBinaryFrame(encodeFileTransferFrame({
3612
+ opcode: FileTransferOpcode.FileEnd,
3613
+ requestId: input.requestId,
3614
+ }));
3615
+ }
3193
3616
  /** Create an empty file or a directory. Never overwrites — see FileCreateResultSchema. */
3194
3617
  async createFileEntry(options) {
3195
3618
  const payload = await this.sendCorrelatedSessionRequest({
@@ -3595,7 +4018,7 @@ export class DaemonClient {
3595
4018
  * torn down when the last disposer runs; events fan out to every caller.
3596
4019
  */
3597
4020
  watchFile(cwd, path, onEvent) {
3598
- const key = `${cwd}${path}`;
4021
+ const key = `${cwd}${path}`;
3599
4022
  const offMessage = this.on("file.watch.event", (message) => {
3600
4023
  if (message.type !== "file.watch.event") {
3601
4024
  return;
@@ -3654,29 +4077,14 @@ export class DaemonClient {
3654
4077
  responseType: "file.upload.response",
3655
4078
  options: { skipQueue: true },
3656
4079
  });
3657
- this.sendBinaryFrame(encodeFileTransferFrame({
3658
- opcode: FileTransferOpcode.FileBegin,
4080
+ this.sendFileTransfer({
3659
4081
  requestId: resolvedRequestId,
3660
- metadata: {
3661
- mime: input.mimeType,
3662
- size: bytes.byteLength,
3663
- encoding: "binary",
3664
- modifiedAt,
3665
- fileName: input.fileName,
3666
- },
3667
- }));
3668
- const chunkSize = input.chunkSize ?? 1024 * 1024;
3669
- for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
3670
- this.sendBinaryFrame(encodeFileTransferFrame({
3671
- opcode: FileTransferOpcode.FileChunk,
3672
- requestId: resolvedRequestId,
3673
- payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)),
3674
- }));
3675
- }
3676
- this.sendBinaryFrame(encodeFileTransferFrame({
3677
- opcode: FileTransferOpcode.FileEnd,
3678
- requestId: resolvedRequestId,
3679
- }));
4082
+ bytes,
4083
+ mime: input.mimeType,
4084
+ fileName: input.fileName,
4085
+ modifiedAt,
4086
+ ...(input.chunkSize === undefined ? {} : { chunkSize: input.chunkSize }),
4087
+ });
3680
4088
  return responsePromise;
3681
4089
  }
3682
4090
  async requestDownloadToken(cwd, path, requestId) {
@@ -3937,6 +4345,249 @@ export class DaemonClient {
3937
4345
  responseType: "set_daemon_config_response",
3938
4346
  });
3939
4347
  }
4348
+ // Enumerate a connector's tools live (connect + listTools), each flagged with
4349
+ // its configured disabled state. Registry edits (add/enable/disable a
4350
+ // connector or a tool) go through patchDaemonConfig's `connectors` instead.
4351
+ async connectorsListTools(connectorId, requestId) {
4352
+ return this.sendCorrelatedSessionRequest({
4353
+ requestId,
4354
+ message: {
4355
+ type: "connectors.list_tools.request",
4356
+ connectorId,
4357
+ },
4358
+ responseType: "connectors.list_tools.response",
4359
+ });
4360
+ }
4361
+ async brainHostStatus(requestId) {
4362
+ const payload = await this.sendCorrelatedSessionRequest({
4363
+ requestId,
4364
+ message: {
4365
+ type: "brain.host.status.request",
4366
+ },
4367
+ responseType: "brain.host.status.response",
4368
+ });
4369
+ return payload.status;
4370
+ }
4371
+ async brainHostStart(model, requestId) {
4372
+ const payload = await this.sendCorrelatedSessionRequest({
4373
+ requestId,
4374
+ message: {
4375
+ type: "brain.host.start.request",
4376
+ model: model ?? null,
4377
+ },
4378
+ responseType: "brain.host.start.response",
4379
+ });
4380
+ return payload.status;
4381
+ }
4382
+ async brainHostStop(requestId) {
4383
+ const payload = await this.sendCorrelatedSessionRequest({
4384
+ requestId,
4385
+ message: {
4386
+ type: "brain.host.stop.request",
4387
+ },
4388
+ responseType: "brain.host.stop.response",
4389
+ });
4390
+ return payload.status;
4391
+ }
4392
+ async brainHostRestart(model, requestId) {
4393
+ const payload = await this.sendCorrelatedSessionRequest({
4394
+ requestId,
4395
+ message: {
4396
+ type: "brain.host.restart.request",
4397
+ model: model ?? null,
4398
+ },
4399
+ responseType: "brain.host.restart.response",
4400
+ });
4401
+ return payload.status;
4402
+ }
4403
+ async brainEvalsGet(requestId) {
4404
+ const payload = await this.sendCorrelatedSessionRequest({
4405
+ requestId,
4406
+ message: {
4407
+ type: "brain.evals.get.request",
4408
+ },
4409
+ responseType: "brain.evals.get.response",
4410
+ });
4411
+ return payload.evals;
4412
+ }
4413
+ async brainRemoteConfigGet(requestId) {
4414
+ const payload = await this.sendCorrelatedSessionRequest({
4415
+ requestId,
4416
+ message: {
4417
+ type: "brain.remote.config.get.request",
4418
+ },
4419
+ responseType: "brain.remote.config.get.response",
4420
+ });
4421
+ if (payload.error) {
4422
+ throw new Error(payload.error);
4423
+ }
4424
+ return payload.config;
4425
+ }
4426
+ async brainRemoteConfigPatch(patch, requestId) {
4427
+ const payload = await this.sendCorrelatedSessionRequest({
4428
+ requestId,
4429
+ message: {
4430
+ type: "brain.remote.config.patch.request",
4431
+ patch,
4432
+ },
4433
+ responseType: "brain.remote.config.patch.response",
4434
+ });
4435
+ if (payload.error) {
4436
+ throw new Error(payload.error);
4437
+ }
4438
+ return payload.config;
4439
+ }
4440
+ async brainModelsList(requestId) {
4441
+ const payload = await this.sendCorrelatedSessionRequest({
4442
+ requestId,
4443
+ message: {
4444
+ type: "brain.models.list.request",
4445
+ },
4446
+ responseType: "brain.models.list.response",
4447
+ });
4448
+ if (payload.error) {
4449
+ throw new Error(payload.error);
4450
+ }
4451
+ return payload.models;
4452
+ }
4453
+ async brainNetworkDiscover(requestId) {
4454
+ const payload = await this.sendCorrelatedSessionRequest({
4455
+ requestId,
4456
+ message: {
4457
+ type: "brain.network.discover.request",
4458
+ },
4459
+ responseType: "brain.network.discover.response",
4460
+ });
4461
+ if (payload.error) {
4462
+ throw new Error(payload.error);
4463
+ }
4464
+ return payload.info;
4465
+ }
4466
+ async brainModelsScan(requestId) {
4467
+ const payload = await this.sendCorrelatedSessionRequest({
4468
+ requestId,
4469
+ message: { type: "brain.models.scan.request" },
4470
+ responseType: "brain.models.scan.response",
4471
+ });
4472
+ if (payload.error) {
4473
+ throw new Error(payload.error);
4474
+ }
4475
+ return payload.models;
4476
+ }
4477
+ async brainCatalogList(requestId) {
4478
+ const payload = await this.sendCorrelatedSessionRequest({
4479
+ requestId,
4480
+ message: { type: "brain.catalog.list.request" },
4481
+ responseType: "brain.catalog.list.response",
4482
+ });
4483
+ if (payload.error) {
4484
+ throw new Error(payload.error);
4485
+ }
4486
+ return payload.models;
4487
+ }
4488
+ async brainRuntimeList(requestId) {
4489
+ const payload = await this.sendCorrelatedSessionRequest({
4490
+ requestId,
4491
+ message: { type: "brain.runtime.list.request" },
4492
+ responseType: "brain.runtime.list.response",
4493
+ });
4494
+ if (payload.error) {
4495
+ throw new Error(payload.error);
4496
+ }
4497
+ return payload.runtimes;
4498
+ }
4499
+ async brainModelsPull(model, requestId) {
4500
+ const payload = await this.sendCorrelatedSessionRequest({
4501
+ requestId,
4502
+ message: { type: "brain.models.pull.request", model },
4503
+ responseType: "brain.models.pull.response",
4504
+ });
4505
+ return unwrapBrainJob(payload);
4506
+ }
4507
+ async brainHfSearch(query, limit, requestId) {
4508
+ const payload = await this.sendCorrelatedSessionRequest({
4509
+ requestId,
4510
+ message: { type: "brain.hf.search.request", query, limit: limit ?? null },
4511
+ responseType: "brain.hf.search.response",
4512
+ });
4513
+ if (payload.error) {
4514
+ throw new Error(payload.error);
4515
+ }
4516
+ return payload.results;
4517
+ }
4518
+ async brainHfQuants(repo, requestId) {
4519
+ const payload = await this.sendCorrelatedSessionRequest({
4520
+ requestId,
4521
+ message: { type: "brain.hf.quants.request", repo },
4522
+ responseType: "brain.hf.quants.response",
4523
+ });
4524
+ if (payload.error) {
4525
+ throw new Error(payload.error);
4526
+ }
4527
+ return payload.quants;
4528
+ }
4529
+ async brainModelsAdd(repo, quant, requestId) {
4530
+ const payload = await this.sendCorrelatedSessionRequest({
4531
+ requestId,
4532
+ message: { type: "brain.models.add.request", repo, quant },
4533
+ responseType: "brain.models.add.response",
4534
+ });
4535
+ return unwrapBrainJob(payload);
4536
+ }
4537
+ async brainRuntimeInstall(build, requestId) {
4538
+ const payload = await this.sendCorrelatedSessionRequest({
4539
+ requestId,
4540
+ message: { type: "brain.runtime.install.request", build: build ?? null },
4541
+ responseType: "brain.runtime.install.response",
4542
+ });
4543
+ return unwrapBrainJob(payload);
4544
+ }
4545
+ async brainCalibrate(model, requestId) {
4546
+ const payload = await this.sendCorrelatedSessionRequest({
4547
+ requestId,
4548
+ message: { type: "brain.calibrate.request", model },
4549
+ responseType: "brain.calibrate.response",
4550
+ });
4551
+ return unwrapBrainJob(payload);
4552
+ }
4553
+ async brainSweep(model, requestId) {
4554
+ const payload = await this.sendCorrelatedSessionRequest({
4555
+ requestId,
4556
+ message: { type: "brain.sweep.request", model },
4557
+ responseType: "brain.sweep.response",
4558
+ });
4559
+ return unwrapBrainJob(payload);
4560
+ }
4561
+ async brainBench(model, requestId) {
4562
+ const payload = await this.sendCorrelatedSessionRequest({
4563
+ requestId,
4564
+ message: { type: "brain.bench.request", model: model ?? null },
4565
+ responseType: "brain.bench.response",
4566
+ });
4567
+ return unwrapBrainJob(payload);
4568
+ }
4569
+ async brainJobsList(requestId) {
4570
+ const payload = await this.sendCorrelatedSessionRequest({
4571
+ requestId,
4572
+ message: { type: "brain.jobs.list.request" },
4573
+ responseType: "brain.jobs.list.response",
4574
+ });
4575
+ if (payload.error) {
4576
+ throw new Error(payload.error);
4577
+ }
4578
+ return payload.jobs;
4579
+ }
4580
+ async brainJobsCancel(jobId, requestId) {
4581
+ const payload = await this.sendCorrelatedSessionRequest({
4582
+ requestId,
4583
+ message: { type: "brain.jobs.cancel.request", jobId },
4584
+ responseType: "brain.jobs.cancel.response",
4585
+ });
4586
+ if (payload.error) {
4587
+ throw new Error(payload.error);
4588
+ }
4589
+ return payload.jobs;
4590
+ }
3940
4591
  async getSpeechSettingsOptions(requestId) {
3941
4592
  return this.sendNamespacedCorrelatedSessionRequest({
3942
4593
  requestId,
@@ -4007,6 +4658,25 @@ export class DaemonClient {
4007
4658
  },
4008
4659
  });
4009
4660
  }
4661
+ // Author a personality profile (the prose personality prompt) from a draft's
4662
+ // name, roles, and spinner colors. Routed through the Writer chain; the caller
4663
+ // drops the result into the editor's prompt field.
4664
+ async generatePersonalityProfile(params, requestId) {
4665
+ return this.sendNamespacedCorrelatedSessionRequest({
4666
+ requestId,
4667
+ message: {
4668
+ type: "agentPersonalities.generate_profile.request",
4669
+ name: params.name,
4670
+ ...(params.roles && params.roles.length > 0 ? { roles: params.roles } : {}),
4671
+ ...(params.glowA ? { glowA: params.glowA } : {}),
4672
+ ...(params.glowB ? { glowB: params.glowB } : {}),
4673
+ ...(params.cwd ? { cwd: params.cwd } : {}),
4674
+ },
4675
+ // Same reason as the voice cues: the daemon spawns a structured-generation
4676
+ // agent, and provider SDK cold starts blow past the 60s default.
4677
+ timeout: 180000,
4678
+ });
4679
+ }
4010
4680
  sendBrowserAutomationExecuteResponse(response) {
4011
4681
  this.sendSessionMessageStrict(response);
4012
4682
  }
@@ -4836,6 +5506,10 @@ export class DaemonClient {
4836
5506
  [CLIENT_CAPS.customModeIcons]: true,
4837
5507
  [CLIENT_CAPS.reasoningMergeEnum]: true,
4838
5508
  [CLIENT_CAPS.terminalReflowableSnapshot]: true,
5509
+ [CLIENT_CAPS.providerSubagents]: true,
5510
+ // The daemon gates project.updated.notification on this (session.ts),
5511
+ // so dropping it silently kills cross-session project renames.
5512
+ [CLIENT_CAPS.projectUpdates]: true,
4839
5513
  ...this.config.capabilities,
4840
5514
  },
4841
5515
  ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
@@ -4925,13 +5599,18 @@ export class DaemonClient {
4925
5599
  }
4926
5600
  const parsed = validateWSOutboundMessage(parsedJson);
4927
5601
  if (!parsed.success) {
4928
- const msgType = parsedJson != null &&
5602
+ const responseIdentity = extractCorrelatedResponseIdentity(parsedJson);
5603
+ const envelopeType = parsedJson != null &&
4929
5604
  typeof parsedJson === "object" &&
4930
5605
  "type" in parsedJson &&
4931
5606
  typeof parsedJson.type === "string"
4932
5607
  ? parsedJson.type
4933
5608
  : "unknown";
5609
+ const msgType = responseIdentity?.responseType ?? envelopeType;
4934
5610
  this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed");
5611
+ if (responseIdentity) {
5612
+ this.rejectWaitersForRequestId(responseIdentity.requestId, new DaemonProtocolError(responseIdentity));
5613
+ }
4935
5614
  return;
4936
5615
  }
4937
5616
  this.consecutiveLivenessFailures = 0;
@@ -5212,6 +5891,18 @@ export class DaemonClient {
5212
5891
  }
5213
5892
  }
5214
5893
  }
5894
+ rejectWaitersForRequestId(requestId, error) {
5895
+ for (const waiter of Array.from(this.waiters)) {
5896
+ if (waiter.requestId !== requestId) {
5897
+ continue;
5898
+ }
5899
+ this.waiters.delete(waiter);
5900
+ if (waiter.timeoutHandle) {
5901
+ clearTimeout(waiter.timeoutHandle);
5902
+ }
5903
+ waiter.reject(error);
5904
+ }
5905
+ }
5215
5906
  clearWaiters(error) {
5216
5907
  for (const waiter of Array.from(this.waiters)) {
5217
5908
  if (waiter.timeoutHandle) {
@@ -5276,7 +5967,7 @@ export class DaemonClient {
5276
5967
  return null;
5277
5968
  }
5278
5969
  }
5279
- waitForWithCancel(predicate, timeout = 30000, _options) {
5970
+ waitForWithCancel(predicate, timeout = 30000, options) {
5280
5971
  // Capture stack trace at call site, not inside setTimeout
5281
5972
  const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`);
5282
5973
  let waiter = null;
@@ -5309,6 +6000,7 @@ export class DaemonClient {
5309
6000
  resolve: wrappedResolve,
5310
6001
  reject: wrappedReject,
5311
6002
  timeoutHandle,
6003
+ requestId: options?.requestId,
5312
6004
  };
5313
6005
  this.waiters.add(waiter);
5314
6006
  });
@@ -5335,6 +6027,80 @@ export class DaemonClient {
5335
6027
  };
5336
6028
  return { promise, cancel };
5337
6029
  }
6030
+ async cloneGithubProject(input, requestId) {
6031
+ const message = {
6032
+ type: "project.github.clone.request",
6033
+ repo: input.repo,
6034
+ targetDirectory: input.targetDirectory,
6035
+ ...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}),
6036
+ };
6037
+ return this.sendNamespacedCorrelatedSessionRequest({
6038
+ requestId,
6039
+ message,
6040
+ timeout: PROJECT_GITHUB_CLONE_TIMEOUT_MS,
6041
+ });
6042
+ }
6043
+ /**
6044
+ * `includeDiscovered` also returns the Scripts the workspace's own project
6045
+ * files declare (package.json scripts today), each tagged with its `source`.
6046
+ * Gate it on `server_info.features.workspaceScriptDiscovery` — an older
6047
+ * daemon ignores the flag and answers with the otto.json list only.
6048
+ */
6049
+ async listWorkspaceScripts(workspaceId, options) {
6050
+ return this.sendCorrelatedSessionRequest({
6051
+ requestId: options?.requestId,
6052
+ message: {
6053
+ type: "workspace.script.list.request",
6054
+ workspaceId,
6055
+ includeDiscovered: options?.includeDiscovered ?? false,
6056
+ },
6057
+ responseType: "workspace.script.list.response",
6058
+ });
6059
+ }
6060
+ async startWorkspaceScriptWithStatus(workspaceId, scriptName, requestId) {
6061
+ return this.sendCorrelatedSessionRequest({
6062
+ requestId,
6063
+ message: { type: "workspace.script.start.request", workspaceId, scriptName },
6064
+ responseType: "workspace.script.start.response",
6065
+ });
6066
+ }
6067
+ async stopWorkspaceScript(workspaceId, scriptName, requestId) {
6068
+ return this.sendCorrelatedSessionRequest({
6069
+ requestId,
6070
+ message: { type: "workspace.script.stop.request", workspaceId, scriptName },
6071
+ responseType: "workspace.script.stop.response",
6072
+ });
6073
+ }
6074
+ async connectHub(hubUrl, token, requestId) {
6075
+ this.requireHubRelationshipSupport();
6076
+ return this.sendCorrelatedSessionRequest({
6077
+ requestId,
6078
+ message: { type: "hub.management.daemon.connect.request", hubUrl, token },
6079
+ responseType: "hub.management.daemon.connect.response",
6080
+ });
6081
+ }
6082
+ async getHubStatus(requestId) {
6083
+ this.requireHubRelationshipSupport();
6084
+ return this.sendCorrelatedSessionRequest({
6085
+ requestId,
6086
+ message: { type: "hub.management.daemon.get_status.request" },
6087
+ responseType: "hub.management.daemon.get_status.response",
6088
+ });
6089
+ }
6090
+ async disconnectHub(force = false, requestId) {
6091
+ this.requireHubRelationshipSupport();
6092
+ return this.sendCorrelatedSessionRequest({
6093
+ requestId,
6094
+ message: { type: "hub.management.daemon.disconnect.request", force },
6095
+ responseType: "hub.management.daemon.disconnect.response",
6096
+ });
6097
+ }
6098
+ requireHubRelationshipSupport() {
6099
+ // COMPAT(hubRelationship): added in v0.2.5, drop the gate when floor >= v0.2.5.
6100
+ if (this.lastServerInfoMessage?.features?.hubRelationship !== true) {
6101
+ throw new Error("Update the host to use Hub relationship management.");
6102
+ }
6103
+ }
5338
6104
  }
5339
6105
  function resolveAgentConfig(options) {
5340
6106
  const { config, provider, cwd, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, images: _images, git: _git, worktreeName: _worktreeName, requestId: _requestId, labels: _labels, ...overrides } = options;