@otto-code/client 0.7.5 → 0.8.0

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) {
@@ -180,6 +225,9 @@ function unwrapBrainJob(payload) {
180
225
  }
181
226
  return payload.job;
182
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;
183
231
  export class DaemonClient {
184
232
  constructor(config) {
185
233
  this.config = config;
@@ -203,6 +251,7 @@ export class DaemonClient {
203
251
  this.connectionState = { status: "idle" };
204
252
  this.checkoutDiffSubscriptions = new Map();
205
253
  this.terminalDirectorySubscriptions = new Map();
254
+ this.fileSubscriptions = new Map();
206
255
  this.terminalStreams = new TerminalStreamRouter();
207
256
  // requestId -> progress listener for an in-flight project.scaffold.request.
208
257
  // Entries are always removed in scaffoldProject's finally block.
@@ -244,7 +293,7 @@ export class DaemonClient {
244
293
  const runtimeMetricsIntervalMs = typeof config.runtimeMetricsIntervalMs === "number" && config.runtimeMetricsIntervalMs > 0
245
294
  ? config.runtimeMetricsIntervalMs
246
295
  : 0;
247
- // The metrics object is always constructed it is a handful of Maps keyed
296
+ // The metrics object is always constructed - it is a handful of Maps keyed
248
297
  // by message type, and its per-message cost is dwarfed by the JSON.parse it
249
298
  // is measuring. What `runtimeMetricsIntervalMs` gates is the *periodic log*,
250
299
  // which is the part that is actually noisy. Keeping the counters on
@@ -670,7 +719,7 @@ export class DaemonClient {
670
719
  return null;
671
720
  }
672
721
  return { kind: "ok", value };
673
- }, timeout, params.options);
722
+ }, timeout, { ...params.options, requestId: params.requestId });
674
723
  try {
675
724
  await this.sendSessionMessageOrThrow(params.message);
676
725
  }
@@ -1276,6 +1325,7 @@ export class DaemonClient {
1276
1325
  ...(options.personality ? { personality: options.personality } : {}),
1277
1326
  ...(options.env ? { env: options.env } : {}),
1278
1327
  ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
1328
+ ...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}),
1279
1329
  ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
1280
1330
  ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
1281
1331
  ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
@@ -1343,7 +1393,7 @@ export class DaemonClient {
1343
1393
  * the whole archived set. Pass `dryRun: true` first to get the count the
1344
1394
  * confirm dialog quotes, then the same call with `dryRun: false` to delete.
1345
1395
  *
1346
- * Removes Otto's records only provider transcripts are left on disk. Gated
1396
+ * Removes Otto's records only - provider transcripts are left on disk. Gated
1347
1397
  * by `server_info.features.historyDelete`; there is no fallback path, so check
1348
1398
  * the flag before offering the action.
1349
1399
  */
@@ -1374,7 +1424,7 @@ export class DaemonClient {
1374
1424
  * `dryRun: false` to delete.
1375
1425
  *
1376
1426
  * Cleared images do not come back: a message that referenced one renders its
1377
- * alt text from then on. Scope is the whole host filenames are a content
1427
+ * alt text from then on. Scope is the whole host - filenames are a content
1378
1428
  * hash, so per-chat or per-workspace scope does not exist. Gated by
1379
1429
  * `server_info.features.attachmentStorage`.
1380
1430
  */
@@ -1440,7 +1490,7 @@ export class DaemonClient {
1440
1490
  }
1441
1491
  /**
1442
1492
  * Stop a running background shell task (Claude Bash tool run_in_background).
1443
- * Not an AI subagent the daemon resolves it to its owning provider task and
1493
+ * Not an AI subagent - the daemon resolves it to its owning provider task and
1444
1494
  * calls the provider's stopTask, same mechanism as stopObservedSubagent.
1445
1495
  */
1446
1496
  async stopBackgroundShellTask(parentAgentId, taskId) {
@@ -1473,7 +1523,7 @@ export class DaemonClient {
1473
1523
  }
1474
1524
  /**
1475
1525
  * Start one or more suggested tasks (from `spawn_task` chips), applying the
1476
- * same mode to each a new worktree workspace per task, a new local session
1526
+ * same mode to each - a new worktree workspace per task, a new local session
1477
1527
  * per task, or steering the parent's current session. Individual actions pass
1478
1528
  * a single-element array; the "Start all" collective action passes the whole
1479
1529
  * pending queue. Resolves to the count started; throws only if all failed.
@@ -1541,7 +1591,7 @@ export class DaemonClient {
1541
1591
  }
1542
1592
  /**
1543
1593
  * Orchestration: delete one finished (or draft) run. Throws with the
1544
- * daemon's reason when it refuses an active run has to be canceled first.
1594
+ * daemon's reason when it refuses - an active run has to be canceled first.
1545
1595
  */
1546
1596
  async deleteRun(runId) {
1547
1597
  const payload = await this.sendNamespacedCorrelatedSessionRequest({
@@ -1853,6 +1903,266 @@ export class DaemonClient {
1853
1903
  },
1854
1904
  });
1855
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
+ }
1856
2166
  async fetchAgentTimeline(agentId, options = {}) {
1857
2167
  const resolvedRequestId = this.createRequestId(options.requestId);
1858
2168
  const message = SessionInboundMessageSchema.parse({
@@ -1890,6 +2200,7 @@ export class DaemonClient {
1890
2200
  type: "agent.fork_context.request",
1891
2201
  agentId,
1892
2202
  requestId: resolvedRequestId,
2203
+ ...(options.boundaryCursor ? { boundaryCursor: options.boundaryCursor } : {}),
1893
2204
  ...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}),
1894
2205
  });
1895
2206
  const payload = await this.sendRequest({
@@ -1987,7 +2298,7 @@ export class DaemonClient {
1987
2298
  }
1988
2299
  /**
1989
2300
  * Move one queued message to a new position. Resolves false when the entry
1990
- * was already drained or was already there the authoritative order arrives
2301
+ * was already drained or was already there - the authoritative order arrives
1991
2302
  * on the agent snapshot either way. Requires
1992
2303
  * `server_info.features.steerQueueReorder`.
1993
2304
  */
@@ -2095,6 +2406,13 @@ export class DaemonClient {
2095
2406
  return msg.payload;
2096
2407
  },
2097
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
+ }
2098
2416
  // Absent ⇒ old daemon that doesn't report whether a run was interrupted.
2099
2417
  return payload.cancelled !== undefined ? { cancelled: payload.cancelled } : {};
2100
2418
  }
@@ -2150,6 +2468,7 @@ export class DaemonClient {
2150
2468
  if (!payload.accepted) {
2151
2469
  throw new Error(payload.error ?? "setAgentModel rejected");
2152
2470
  }
2471
+ return payload.notice ?? null;
2153
2472
  }
2154
2473
  async setAgentFeature(agentId, featureId, value) {
2155
2474
  const requestId = this.createRequestId();
@@ -2208,7 +2527,7 @@ export class DaemonClient {
2208
2527
  /**
2209
2528
  * Live-switch a running agent's personality (null clears it). The daemon
2210
2529
  * re-resolves the roster id against the agent's cwd and applies the full
2211
- * personality system prompt, identity, model/mode/effort restarting the
2530
+ * personality - system prompt, identity, model/mode/effort - restarting the
2212
2531
  * provider query so the prompt takes effect on the next turn. Gate on
2213
2532
  * server_info.features.setAgentPersonality.
2214
2533
  */
@@ -2300,7 +2619,7 @@ export class DaemonClient {
2300
2619
  return this.sendRequest({
2301
2620
  requestId: resolvedRequestId,
2302
2621
  message,
2303
- timeout: 300000, // 5 minutes npm update can be slow on remote machines
2622
+ timeout: 300000, // 5 minutes - npm update can be slow on remote machines
2304
2623
  options: { skipQueue: true },
2305
2624
  select: (msg) => {
2306
2625
  const parsed = DaemonUpdateResponseSchema.safeParse(msg);
@@ -2698,7 +3017,7 @@ export class DaemonClient {
2698
3017
  });
2699
3018
  }
2700
3019
  // ── Git file investigation ────────────────────────────────────────────────
2701
- // Local git only: no remote, no forge, and no per-provider variant the same
3020
+ // Local git only: no remote, no forge, and no per-provider variant - the same
2702
3021
  // four calls answer for every agent provider. Gated by
2703
3022
  // server_info.features.checkoutGitFileHistory.
2704
3023
  async checkoutGitFileHistory(cwd, input, requestId) {
@@ -3049,6 +3368,20 @@ export class DaemonClient {
3049
3368
  responseType: "branch_suggestions_response",
3050
3369
  });
3051
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
+ }
3052
3385
  async searchGitHub(options, requestId) {
3053
3386
  return this.sendCorrelatedSessionRequest({
3054
3387
  requestId,
@@ -3177,13 +3510,25 @@ export class DaemonClient {
3177
3510
  content: file.content,
3178
3511
  size: file.size,
3179
3512
  modifiedAt: file.modifiedAt,
3180
- // COMPAT(textEditor): added in v0.4.4 the editor is gated on
3513
+ // COMPAT(textEditor): added in v0.4.4 - the editor is gated on
3181
3514
  // features.textEditor, so a gated daemon always sends both fields.
3182
3515
  eol: file.eol ?? "lf",
3183
3516
  hash: file.hash ?? null,
3184
3517
  };
3185
3518
  }
3186
- /** Conditional save — see FileWriteRequestSchema for the no-clobber contract. */
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
+ }
3531
+ /** Conditional save - see FileWriteRequestSchema for the no-clobber contract. */
3187
3532
  async writeFile(options) {
3188
3533
  const payload = await this.sendCorrelatedSessionRequest({
3189
3534
  requestId: options.requestId,
@@ -3201,7 +3546,74 @@ export class DaemonClient {
3201
3546
  });
3202
3547
  return payload.result;
3203
3548
  }
3204
- /** Create an empty file or a directory. Never overwrites — see FileCreateResultSchema. */
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
+ }
3616
+ /** Create an empty file or a directory. Never overwrites - see FileCreateResultSchema. */
3205
3617
  async createFileEntry(options) {
3206
3618
  const payload = await this.sendCorrelatedSessionRequest({
3207
3619
  requestId: options.requestId,
@@ -3215,7 +3627,7 @@ export class DaemonClient {
3215
3627
  });
3216
3628
  return payload.result;
3217
3629
  }
3218
- /** Permanent delete an unlink, not a move to any trash. */
3630
+ /** Permanent delete - an unlink, not a move to any trash. */
3219
3631
  async deleteFileEntry(options) {
3220
3632
  const payload = await this.sendCorrelatedSessionRequest({
3221
3633
  requestId: options.requestId,
@@ -3337,7 +3749,7 @@ export class DaemonClient {
3337
3749
  }
3338
3750
  /**
3339
3751
  * Mirror the editor's current buffer to the daemon so definitions resolve against
3340
- * unsaved edits. Debounced by the caller this is not a per-keystroke RPC.
3752
+ * unsaved edits. Debounced by the caller - this is not a per-keystroke RPC.
3341
3753
  */
3342
3754
  async syncCodeDocument(cwd, path, text, requestId) {
3343
3755
  const payload = await this.sendCorrelatedSessionRequest({
@@ -3401,7 +3813,7 @@ export class DaemonClient {
3401
3813
  return { status: payload.status, locations: payload.locations, error: payload.error };
3402
3814
  }
3403
3815
  /**
3404
- * A rename **dry run** every edit it would make, and nothing written. The client
3816
+ * A rename **dry run** - every edit it would make, and nothing written. The client
3405
3817
  * puts this in front of the user as a job to audit before applying.
3406
3818
  */
3407
3819
  async previewCodeRename(input, requestId) {
@@ -3512,7 +3924,7 @@ export class DaemonClient {
3512
3924
  *
3513
3925
  * Never throws and never carries an error the caller has to render. A workspace with no
3514
3926
  * solution, a host with no .NET SDK, and a host with the feature switched off all answer with an
3515
- * empty list, so the caller has one silent case "no switcher" rather than four states.
3927
+ * empty list, so the caller has one silent case - "no switcher" - rather than four states.
3516
3928
  */
3517
3929
  async listSolutions(cwd, requestId) {
3518
3930
  const payload = await this.sendCorrelatedSessionRequest({
@@ -3587,7 +3999,7 @@ export class DaemonClient {
3587
3999
  }
3588
4000
  return payload.symbols;
3589
4001
  }
3590
- /** Preview-first project replace see FileReplaceRequestSchema. */
4002
+ /** Preview-first project replace - see FileReplaceRequestSchema. */
3591
4003
  async replaceFiles(options) {
3592
4004
  return this.sendCorrelatedSessionRequest({
3593
4005
  requestId: options.requestId,
@@ -3606,7 +4018,7 @@ export class DaemonClient {
3606
4018
  * torn down when the last disposer runs; events fan out to every caller.
3607
4019
  */
3608
4020
  watchFile(cwd, path, onEvent) {
3609
- const key = `${cwd}${path}`;
4021
+ const key = `${cwd}${path}`;
3610
4022
  const offMessage = this.on("file.watch.event", (message) => {
3611
4023
  if (message.type !== "file.watch.event") {
3612
4024
  return;
@@ -3665,29 +4077,14 @@ export class DaemonClient {
3665
4077
  responseType: "file.upload.response",
3666
4078
  options: { skipQueue: true },
3667
4079
  });
3668
- this.sendBinaryFrame(encodeFileTransferFrame({
3669
- opcode: FileTransferOpcode.FileBegin,
3670
- requestId: resolvedRequestId,
3671
- metadata: {
3672
- mime: input.mimeType,
3673
- size: bytes.byteLength,
3674
- encoding: "binary",
3675
- modifiedAt,
3676
- fileName: input.fileName,
3677
- },
3678
- }));
3679
- const chunkSize = input.chunkSize ?? 1024 * 1024;
3680
- for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
3681
- this.sendBinaryFrame(encodeFileTransferFrame({
3682
- opcode: FileTransferOpcode.FileChunk,
3683
- requestId: resolvedRequestId,
3684
- payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)),
3685
- }));
3686
- }
3687
- this.sendBinaryFrame(encodeFileTransferFrame({
3688
- opcode: FileTransferOpcode.FileEnd,
4080
+ this.sendFileTransfer({
3689
4081
  requestId: resolvedRequestId,
3690
- }));
4082
+ bytes,
4083
+ mime: input.mimeType,
4084
+ fileName: input.fileName,
4085
+ modifiedAt,
4086
+ ...(input.chunkSize === undefined ? {} : { chunkSize: input.chunkSize }),
4087
+ });
3691
4088
  return responsePromise;
3692
4089
  }
3693
4090
  async requestDownloadToken(cwd, path, requestId) {
@@ -3774,7 +4171,7 @@ export class DaemonClient {
3774
4171
  *
3775
4172
  * Pass `workspaceId` whenever the write may be project-scoped: the daemon
3776
4173
  * binds the entry to the repo root that workspace resolves to, and an entry
3777
- * scoped to "project" with no root is filtered out of every brief stored,
4174
+ * scoped to "project" with no root is filtered out of every brief - stored,
3778
4175
  * listed, and never sent.
3779
4176
  */
3780
4177
  async updatePersonalityMemory(input, requestId) {
@@ -3961,11 +4358,45 @@ export class DaemonClient {
3961
4358
  responseType: "connectors.list_tools.response",
3962
4359
  });
3963
4360
  }
3964
- async brainHostStatus(requestId) {
4361
+ /**
4362
+ * Start a connector's OAuth login. Resolves with the URL to open, or with
4363
+ * status "authorized" when the daemon already held a usable token. The login
4364
+ * itself settles later on the `connectors.oauth.status` push, because the user
4365
+ * is in a browser by then.
4366
+ */
4367
+ async connectorsOauthAuthorize(connectorId, scope, requestId) {
4368
+ return this.sendCorrelatedSessionRequest({
4369
+ requestId,
4370
+ message: {
4371
+ type: "connectors.oauth.authorize.request",
4372
+ connectorId,
4373
+ ...(scope ? { scope } : {}),
4374
+ },
4375
+ responseType: "connectors.oauth.authorize.response",
4376
+ });
4377
+ }
4378
+ /** Drop a connector's stored authorization. */
4379
+ async connectorsOauthDisconnect(connectorId, requestId) {
4380
+ return this.sendCorrelatedSessionRequest({
4381
+ requestId,
4382
+ message: {
4383
+ type: "connectors.oauth.disconnect.request",
4384
+ connectorId,
4385
+ },
4386
+ responseType: "connectors.oauth.disconnect.response",
4387
+ });
4388
+ }
4389
+ /**
4390
+ * The brain's status. Pass `resources` only from a surface that renders the
4391
+ * live CPU/RAM/GPU/slot numbers: it costs an `nvidia-smi` spawn plus a /slots
4392
+ * round trip on the brain, and this call is also the liveness poll.
4393
+ */
4394
+ async brainHostStatus(options, requestId) {
3965
4395
  const payload = await this.sendCorrelatedSessionRequest({
3966
4396
  requestId,
3967
4397
  message: {
3968
4398
  type: "brain.host.status.request",
4399
+ resources: options?.resources ?? false,
3969
4400
  },
3970
4401
  responseType: "brain.host.status.response",
3971
4402
  });
@@ -4191,6 +4622,123 @@ export class DaemonClient {
4191
4622
  }
4192
4623
  return payload.jobs;
4193
4624
  }
4625
+ // --- Brain Console --------------------------------------------------------
4626
+ // These proxy the brain's own /__host/* management API and work against a
4627
+ // local or a remote brain identically. Gated by features.brainConsole on the
4628
+ // daemon, and by `capabilities` on brain.host.status for the brain itself.
4629
+ // Each throws the brain's own message on failure, because "could not delete
4630
+ // the model" with no reason is not a usable error.
4631
+ /**
4632
+ * The joined model inventory: scan row, GGUF metadata, saved profile,
4633
+ * calibration state, VRAM budget and benchmark score per model, plus disk
4634
+ * usage. One call feeds the whole Models tab.
4635
+ */
4636
+ async brainModelsInventory(requestId) {
4637
+ const payload = await this.sendCorrelatedSessionRequest({
4638
+ requestId,
4639
+ message: { type: "brain.models.inventory.request" },
4640
+ responseType: "brain.models.inventory.response",
4641
+ });
4642
+ if (payload.error) {
4643
+ throw new Error(payload.error);
4644
+ }
4645
+ return { models: payload.models, disk: payload.disk };
4646
+ }
4647
+ /** One model's saved profile, the field descriptors, and its warnings. */
4648
+ async brainModelProfileGet(modelId, requestId) {
4649
+ const payload = await this.sendCorrelatedSessionRequest({
4650
+ requestId,
4651
+ message: { type: "brain.model.profile.get.request", modelId },
4652
+ responseType: "brain.model.profile.get.response",
4653
+ });
4654
+ if (payload.error) {
4655
+ throw new Error(payload.error);
4656
+ }
4657
+ return payload;
4658
+ }
4659
+ /**
4660
+ * Write the editable profile fields. The reply carries the recomputed budget,
4661
+ * so an edit costs one round trip rather than a write the UI has to follow
4662
+ * with a read.
4663
+ */
4664
+ async brainModelProfileSet(modelId, patch, requestId) {
4665
+ const payload = await this.sendCorrelatedSessionRequest({
4666
+ requestId,
4667
+ message: { type: "brain.model.profile.set.request", modelId, patch },
4668
+ responseType: "brain.model.profile.set.response",
4669
+ });
4670
+ if (payload.error) {
4671
+ throw new Error(payload.error);
4672
+ }
4673
+ return payload;
4674
+ }
4675
+ /**
4676
+ * The VRAM budget for a hypothetical profile. `overrides` are string-encoded
4677
+ * field values, so the UI can preview the budget while a control is mid-drag
4678
+ * without persisting a value the user is scrubbing past.
4679
+ */
4680
+ async brainModelBudget(modelId, overrides, requestId) {
4681
+ const payload = await this.sendCorrelatedSessionRequest({
4682
+ requestId,
4683
+ message: { type: "brain.model.budget.get.request", modelId, overrides: overrides ?? {} },
4684
+ responseType: "brain.model.budget.get.response",
4685
+ });
4686
+ if (payload.error) {
4687
+ throw new Error(payload.error);
4688
+ }
4689
+ return payload;
4690
+ }
4691
+ /**
4692
+ * Load a model into the running brain. This is not `brainHostStart`, which
4693
+ * restarts the daemon's child process and has no remote equivalent.
4694
+ */
4695
+ async brainModelLoad(modelId, requestId) {
4696
+ const payload = await this.sendCorrelatedSessionRequest({
4697
+ requestId,
4698
+ message: { type: "brain.model.load.request", modelId },
4699
+ responseType: "brain.model.load.response",
4700
+ });
4701
+ if (payload.error) {
4702
+ throw new Error(payload.error);
4703
+ }
4704
+ return payload;
4705
+ }
4706
+ /** Unload the resident model, leaving the brain up and serving nothing. */
4707
+ async brainModelUnload(requestId) {
4708
+ const payload = await this.sendCorrelatedSessionRequest({
4709
+ requestId,
4710
+ message: { type: "brain.model.unload.request" },
4711
+ responseType: "brain.model.unload.response",
4712
+ });
4713
+ if (payload.error) {
4714
+ throw new Error(payload.error);
4715
+ }
4716
+ return payload.status;
4717
+ }
4718
+ /** Delete a model's files. The brain refuses while that model is loaded. */
4719
+ async brainModelDelete(modelId, requestId) {
4720
+ const payload = await this.sendCorrelatedSessionRequest({
4721
+ requestId,
4722
+ message: { type: "brain.model.delete.request", modelId },
4723
+ responseType: "brain.model.delete.response",
4724
+ });
4725
+ if (payload.error) {
4726
+ throw new Error(payload.error);
4727
+ }
4728
+ return payload;
4729
+ }
4730
+ /** Tail the brain's llama-server log. */
4731
+ async brainLogsTail(limit, requestId) {
4732
+ const payload = await this.sendCorrelatedSessionRequest({
4733
+ requestId,
4734
+ message: { type: "brain.logs.tail.request", limit: limit ?? null },
4735
+ responseType: "brain.logs.tail.response",
4736
+ });
4737
+ if (payload.error) {
4738
+ throw new Error(payload.error);
4739
+ }
4740
+ return payload;
4741
+ }
4194
4742
  async getSpeechSettingsOptions(requestId) {
4195
4743
  return this.sendNamespacedCorrelatedSessionRequest({
4196
4744
  requestId,
@@ -4336,7 +4884,7 @@ export class DaemonClient {
4336
4884
  },
4337
4885
  });
4338
4886
  }
4339
- /** Daemon-wide "fun stats" see docs/data-model.md ActivityStatsStore. */
4887
+ /** Daemon-wide "fun stats" - see docs/data-model.md ActivityStatsStore. */
4340
4888
  async getActivityStats(options) {
4341
4889
  return this.sendNamespacedCorrelatedSessionRequest({
4342
4890
  requestId: options?.requestId,
@@ -4354,7 +4902,7 @@ export class DaemonClient {
4354
4902
  },
4355
4903
  });
4356
4904
  }
4357
- /** Itemized usage ledger the scrollable rows behind the stats tiles (usage-ledger). */
4905
+ /** Itemized usage ledger - the scrollable rows behind the stats tiles (usage-ledger). */
4358
4906
  async getUsageLog(options) {
4359
4907
  return this.sendNamespacedCorrelatedSessionRequest({
4360
4908
  requestId: options?.requestId,
@@ -5078,7 +5626,7 @@ export class DaemonClient {
5078
5626
  /**
5079
5627
  * Session totals for inbound daemon traffic, including the main-thread time
5080
5628
  * spent handling it. Null when runtime metrics are disabled for this client.
5081
- * Read by the app's resource monitor the wire is a first-class suspect when
5629
+ * Read by the app's resource monitor - the wire is a first-class suspect when
5082
5630
  * the UI thread degrades, so it has to be measurable rather than inferred.
5083
5631
  */
5084
5632
  getTrafficTotals() {
@@ -5109,6 +5657,10 @@ export class DaemonClient {
5109
5657
  [CLIENT_CAPS.customModeIcons]: true,
5110
5658
  [CLIENT_CAPS.reasoningMergeEnum]: true,
5111
5659
  [CLIENT_CAPS.terminalReflowableSnapshot]: true,
5660
+ [CLIENT_CAPS.providerSubagents]: true,
5661
+ // The daemon gates project.updated.notification on this (session.ts),
5662
+ // so dropping it silently kills cross-session project renames.
5663
+ [CLIENT_CAPS.projectUpdates]: true,
5112
5664
  ...this.config.capabilities,
5113
5665
  },
5114
5666
  ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
@@ -5198,13 +5750,18 @@ export class DaemonClient {
5198
5750
  }
5199
5751
  const parsed = validateWSOutboundMessage(parsedJson);
5200
5752
  if (!parsed.success) {
5201
- const msgType = parsedJson != null &&
5753
+ const responseIdentity = extractCorrelatedResponseIdentity(parsedJson);
5754
+ const envelopeType = parsedJson != null &&
5202
5755
  typeof parsedJson === "object" &&
5203
5756
  "type" in parsedJson &&
5204
5757
  typeof parsedJson.type === "string"
5205
5758
  ? parsedJson.type
5206
5759
  : "unknown";
5760
+ const msgType = responseIdentity?.responseType ?? envelopeType;
5207
5761
  this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed");
5762
+ if (responseIdentity) {
5763
+ this.rejectWaitersForRequestId(responseIdentity.requestId, new DaemonProtocolError(responseIdentity));
5764
+ }
5208
5765
  return;
5209
5766
  }
5210
5767
  this.consecutiveLivenessFailures = 0;
@@ -5485,6 +6042,18 @@ export class DaemonClient {
5485
6042
  }
5486
6043
  }
5487
6044
  }
6045
+ rejectWaitersForRequestId(requestId, error) {
6046
+ for (const waiter of Array.from(this.waiters)) {
6047
+ if (waiter.requestId !== requestId) {
6048
+ continue;
6049
+ }
6050
+ this.waiters.delete(waiter);
6051
+ if (waiter.timeoutHandle) {
6052
+ clearTimeout(waiter.timeoutHandle);
6053
+ }
6054
+ waiter.reject(error);
6055
+ }
6056
+ }
5488
6057
  clearWaiters(error) {
5489
6058
  for (const waiter of Array.from(this.waiters)) {
5490
6059
  if (waiter.timeoutHandle) {
@@ -5549,7 +6118,7 @@ export class DaemonClient {
5549
6118
  return null;
5550
6119
  }
5551
6120
  }
5552
- waitForWithCancel(predicate, timeout = 30000, _options) {
6121
+ waitForWithCancel(predicate, timeout = 30000, options) {
5553
6122
  // Capture stack trace at call site, not inside setTimeout
5554
6123
  const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`);
5555
6124
  let waiter = null;
@@ -5582,6 +6151,7 @@ export class DaemonClient {
5582
6151
  resolve: wrappedResolve,
5583
6152
  reject: wrappedReject,
5584
6153
  timeoutHandle,
6154
+ requestId: options?.requestId,
5585
6155
  };
5586
6156
  this.waiters.add(waiter);
5587
6157
  });
@@ -5608,6 +6178,80 @@ export class DaemonClient {
5608
6178
  };
5609
6179
  return { promise, cancel };
5610
6180
  }
6181
+ async cloneGithubProject(input, requestId) {
6182
+ const message = {
6183
+ type: "project.github.clone.request",
6184
+ repo: input.repo,
6185
+ targetDirectory: input.targetDirectory,
6186
+ ...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}),
6187
+ };
6188
+ return this.sendNamespacedCorrelatedSessionRequest({
6189
+ requestId,
6190
+ message,
6191
+ timeout: PROJECT_GITHUB_CLONE_TIMEOUT_MS,
6192
+ });
6193
+ }
6194
+ /**
6195
+ * `includeDiscovered` also returns the Scripts the workspace's own project
6196
+ * files declare (package.json scripts today), each tagged with its `source`.
6197
+ * Gate it on `server_info.features.workspaceScriptDiscovery` - an older
6198
+ * daemon ignores the flag and answers with the otto.json list only.
6199
+ */
6200
+ async listWorkspaceScripts(workspaceId, options) {
6201
+ return this.sendCorrelatedSessionRequest({
6202
+ requestId: options?.requestId,
6203
+ message: {
6204
+ type: "workspace.script.list.request",
6205
+ workspaceId,
6206
+ includeDiscovered: options?.includeDiscovered ?? false,
6207
+ },
6208
+ responseType: "workspace.script.list.response",
6209
+ });
6210
+ }
6211
+ async startWorkspaceScriptWithStatus(workspaceId, scriptName, requestId) {
6212
+ return this.sendCorrelatedSessionRequest({
6213
+ requestId,
6214
+ message: { type: "workspace.script.start.request", workspaceId, scriptName },
6215
+ responseType: "workspace.script.start.response",
6216
+ });
6217
+ }
6218
+ async stopWorkspaceScript(workspaceId, scriptName, requestId) {
6219
+ return this.sendCorrelatedSessionRequest({
6220
+ requestId,
6221
+ message: { type: "workspace.script.stop.request", workspaceId, scriptName },
6222
+ responseType: "workspace.script.stop.response",
6223
+ });
6224
+ }
6225
+ async connectHub(hubUrl, token, requestId) {
6226
+ this.requireHubRelationshipSupport();
6227
+ return this.sendCorrelatedSessionRequest({
6228
+ requestId,
6229
+ message: { type: "hub.management.daemon.connect.request", hubUrl, token },
6230
+ responseType: "hub.management.daemon.connect.response",
6231
+ });
6232
+ }
6233
+ async getHubStatus(requestId) {
6234
+ this.requireHubRelationshipSupport();
6235
+ return this.sendCorrelatedSessionRequest({
6236
+ requestId,
6237
+ message: { type: "hub.management.daemon.get_status.request" },
6238
+ responseType: "hub.management.daemon.get_status.response",
6239
+ });
6240
+ }
6241
+ async disconnectHub(force = false, requestId) {
6242
+ this.requireHubRelationshipSupport();
6243
+ return this.sendCorrelatedSessionRequest({
6244
+ requestId,
6245
+ message: { type: "hub.management.daemon.disconnect.request", force },
6246
+ responseType: "hub.management.daemon.disconnect.response",
6247
+ });
6248
+ }
6249
+ requireHubRelationshipSupport() {
6250
+ // COMPAT(hubRelationship): added in v0.2.5, drop the gate when floor >= v0.2.5.
6251
+ if (this.lastServerInfoMessage?.features?.hubRelationship !== true) {
6252
+ throw new Error("Update the host to use Hub relationship management.");
6253
+ }
6254
+ }
5611
6255
  }
5612
6256
  function resolveAgentConfig(options) {
5613
6257
  const { config, provider, cwd, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, images: _images, git: _git, worktreeName: _worktreeName, requestId: _requestId, labels: _labels, ...overrides } = options;