@otto-code/client 0.6.7 → 0.7.1

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.
@@ -5,7 +5,7 @@ import { isRelayClientWebSocketUrl } from "@otto-code/protocol/daemon-endpoints"
5
5
  import { terminalSubscriptionKey } from "@otto-code/protocol/terminal-subscription-key";
6
6
  import { asUint8Array, decodeFileTransferFrame, encodeFileTransferFrame, decodeTerminalStreamFrame, FileTransferOpcode, TerminalStreamOpcode, } from "@otto-code/protocol/binary-frames/index";
7
7
  import { createRelayE2eeTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, describeTransportClose, describeTransportError, } from "./daemon-client-transport.js";
8
- import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
8
+ import { DaemonClientRuntimeMetrics, } from "./daemon-client-runtime-metrics.js";
9
9
  import { normalizeListProviderModelsPayload, normalizeProviderSnapshotUpdateMessage, normalizeProvidersSnapshotPayload, } from "./compat/normalize-provider-models.js";
10
10
  import { TerminalStreamRouter } from "./terminal-stream-router.js";
11
11
  const consoleLogger = {
@@ -193,6 +193,9 @@ export class DaemonClient {
193
193
  this.checkoutDiffSubscriptions = new Map();
194
194
  this.terminalDirectorySubscriptions = new Map();
195
195
  this.terminalStreams = new TerminalStreamRouter();
196
+ // requestId -> progress listener for an in-flight project.scaffold.request.
197
+ // Entries are always removed in scaffoldProject's finally block.
198
+ this.scaffoldProgressListeners = new Map();
196
199
  this.pendingBinaryFileReads = new Map();
197
200
  this.activeBinaryFileTransfers = new Map();
198
201
  this.completedBinaryFileReads = new Map();
@@ -230,15 +233,23 @@ export class DaemonClient {
230
233
  const runtimeMetricsIntervalMs = typeof config.runtimeMetricsIntervalMs === "number" && config.runtimeMetricsIntervalMs > 0
231
234
  ? config.runtimeMetricsIntervalMs
232
235
  : 0;
236
+ // The metrics object is always constructed — it is a handful of Maps keyed
237
+ // by message type, and its per-message cost is dwarfed by the JSON.parse it
238
+ // is measuring. What `runtimeMetricsIntervalMs` gates is the *periodic log*,
239
+ // which is the part that is actually noisy. Keeping the counters on
240
+ // unconditionally is what lets the app read cumulative traffic (see
241
+ // getTrafficTotals) without every embedder having to opt in; before this
242
+ // split, nothing in the app package passed the interval, so client-side wire
243
+ // accounting existed but never ran.
244
+ const runtimeMetricsWindowMs = typeof config.runtimeMetricsWindowMs === "number" && config.runtimeMetricsWindowMs > 0
245
+ ? Math.max(config.runtimeMetricsWindowMs, runtimeMetricsIntervalMs)
246
+ : undefined;
247
+ this.runtimeMetrics = new DaemonClientRuntimeMetrics(this.logger, {
248
+ connectionPath: this.logConnectionPath,
249
+ serverId: this.logServerId,
250
+ getConnectionStatus: () => this.connectionState.status,
251
+ }, runtimeMetricsWindowMs ? { windowMs: runtimeMetricsWindowMs } : undefined);
233
252
  if (runtimeMetricsIntervalMs > 0) {
234
- const runtimeMetricsWindowMs = typeof config.runtimeMetricsWindowMs === "number" && config.runtimeMetricsWindowMs > 0
235
- ? Math.max(config.runtimeMetricsWindowMs, runtimeMetricsIntervalMs)
236
- : undefined;
237
- this.runtimeMetrics = new DaemonClientRuntimeMetrics(this.logger, {
238
- connectionPath: this.logConnectionPath,
239
- serverId: this.logServerId,
240
- getConnectionStatus: () => this.connectionState.status,
241
- }, runtimeMetricsWindowMs ? { windowMs: runtimeMetricsWindowMs } : undefined);
242
253
  this.runtimeMetricsInterval = setInterval(() => {
243
254
  this.runtimeMetrics?.flush();
244
255
  }, runtimeMetricsIntervalMs);
@@ -445,9 +456,11 @@ export class DaemonClient {
445
456
  if (this.runtimeMetricsInterval) {
446
457
  clearInterval(this.runtimeMetricsInterval);
447
458
  this.runtimeMetricsInterval = null;
459
+ // Only the interval-logging clients emit the closing window; a
460
+ // counters-only client has nothing to log.
448
461
  this.runtimeMetrics?.flush({ final: true });
449
- this.runtimeMetrics = null;
450
462
  }
463
+ this.runtimeMetrics = null;
451
464
  this.updateConnectionState({ status: "disposed" }, { event: "DISPOSE", reason: "Client closed", reasonCode: "disposed" });
452
465
  }
453
466
  ensureConnected() {
@@ -1022,6 +1035,54 @@ export class DaemonClient {
1022
1035
  responseType: "project.add.response",
1023
1036
  });
1024
1037
  }
1038
+ // Creates a project directory from scratch and registers it. Requires
1039
+ // server_info features.projectScaffold. `onProgress` is optional: the
1040
+ // resolved payload carries the authoritative step list either way.
1041
+ async scaffoldProject(options, requestId) {
1042
+ // Resolved here rather than inside sendCorrelatedSessionRequest so the
1043
+ // progress listener is registered under the same id before the send.
1044
+ const resolvedRequestId = this.createRequestId(requestId);
1045
+ if (options.onProgress) {
1046
+ this.scaffoldProgressListeners.set(resolvedRequestId, options.onProgress);
1047
+ }
1048
+ try {
1049
+ return await this.sendCorrelatedSessionRequest({
1050
+ requestId: resolvedRequestId,
1051
+ message: {
1052
+ type: "project.scaffold.request",
1053
+ parentDirectory: options.parentDirectory,
1054
+ folderName: options.folderName,
1055
+ git: options.git,
1056
+ },
1057
+ responseType: "project.scaffold.response",
1058
+ });
1059
+ }
1060
+ finally {
1061
+ this.scaffoldProgressListeners.delete(resolvedRequestId);
1062
+ }
1063
+ }
1064
+ async listHostingRepositories(options, requestId) {
1065
+ return this.sendCorrelatedSessionRequest({
1066
+ requestId,
1067
+ message: {
1068
+ type: "hosting.list_repositories.request",
1069
+ provider: options.provider,
1070
+ query: options.query,
1071
+ limit: options.limit,
1072
+ },
1073
+ responseType: "hosting.list_repositories.response",
1074
+ });
1075
+ }
1076
+ async listHostingOwners(options, requestId) {
1077
+ return this.sendCorrelatedSessionRequest({
1078
+ requestId,
1079
+ message: {
1080
+ type: "hosting.list_owners.request",
1081
+ provider: options.provider,
1082
+ },
1083
+ responseType: "hosting.list_owners.response",
1084
+ });
1085
+ }
1025
1086
  async startWorkspaceScript(workspaceId, scriptName, requestId) {
1026
1087
  return this.sendCorrelatedSessionRequest({
1027
1088
  requestId,
@@ -1056,6 +1117,20 @@ export class DaemonClient {
1056
1117
  responseType: "workspace.archive.preflight.response",
1057
1118
  });
1058
1119
  }
1120
+ // Repoint a worktree-backed workspace's base branch (what Changes diffs against,
1121
+ // and what merge-into-base / PR creation target). Pass null to reset to the
1122
+ // repository default. Gated by server_info.features.worktreeDiffBase.
1123
+ async setWorktreeBaseRef(workspaceId, baseRef, requestId) {
1124
+ return this.sendCorrelatedSessionRequest({
1125
+ requestId,
1126
+ message: {
1127
+ type: "worktree.baseRef.set.request",
1128
+ workspaceId,
1129
+ baseRef,
1130
+ },
1131
+ responseType: "worktree.baseRef.set.response",
1132
+ });
1133
+ }
1059
1134
  // List re-attachable Otto worktrees for a project (or the repo containing cwd):
1060
1135
  // archived worktree workspaces with a kept branch, plus orphaned on-disk
1061
1136
  // worktrees. Gated by server_info.features.worktreeReattach.
@@ -1246,6 +1321,57 @@ export class DaemonClient {
1246
1321
  },
1247
1322
  });
1248
1323
  }
1324
+ /**
1325
+ * Bulk-delete archived chat records on this host. Server-side by necessity:
1326
+ * the client's history list is cursor-paginated across hosts and never holds
1327
+ * the whole archived set. Pass `dryRun: true` first to get the count the
1328
+ * confirm dialog quotes, then the same call with `dryRun: false` to delete.
1329
+ *
1330
+ * Removes Otto's records only — provider transcripts are left on disk. Gated
1331
+ * by `server_info.features.historyDelete`; there is no fallback path, so check
1332
+ * the flag before offering the action.
1333
+ */
1334
+ async clearArchivedAgents(options) {
1335
+ return this.sendNamespacedCorrelatedSessionRequest({
1336
+ requestId: options.requestId,
1337
+ message: {
1338
+ type: "history.agents.clear_archived.request",
1339
+ dryRun: options.dryRun,
1340
+ olderThanDays: options.olderThanDays ?? 0,
1341
+ },
1342
+ });
1343
+ }
1344
+ /**
1345
+ * How much disk the images agents produced occupy on this host, plus the
1346
+ * retention policy currently ageing them out. Gated by
1347
+ * `server_info.features.attachmentStorage`.
1348
+ */
1349
+ async getAttachmentImageStats(requestId) {
1350
+ return this.sendNamespacedCorrelatedSessionRequest({
1351
+ requestId,
1352
+ message: { type: "attachments.images.get_stats.request" },
1353
+ });
1354
+ }
1355
+ /**
1356
+ * Reclaims the materialized image store. Call once with `dryRun: true` for
1357
+ * the count and size the confirm dialog quotes, then again with
1358
+ * `dryRun: false` to delete.
1359
+ *
1360
+ * Cleared images do not come back: a message that referenced one renders its
1361
+ * alt text from then on. Scope is the whole host — filenames are a content
1362
+ * hash, so per-chat or per-workspace scope does not exist. Gated by
1363
+ * `server_info.features.attachmentStorage`.
1364
+ */
1365
+ async clearAttachmentImages(options) {
1366
+ return this.sendNamespacedCorrelatedSessionRequest({
1367
+ requestId: options.requestId,
1368
+ message: {
1369
+ type: "attachments.images.clear.request",
1370
+ dryRun: options.dryRun,
1371
+ olderThanDays: options.olderThanDays ?? 0,
1372
+ },
1373
+ });
1374
+ }
1249
1375
  async archiveAgent(agentId) {
1250
1376
  const requestId = this.createRequestId();
1251
1377
  const message = SessionInboundMessageSchema.parse({
@@ -1397,6 +1523,19 @@ export class DaemonClient {
1397
1523
  });
1398
1524
  return payload.runIds;
1399
1525
  }
1526
+ /**
1527
+ * Orchestration: delete one finished (or draft) run. Throws with the
1528
+ * daemon's reason when it refuses — an active run has to be canceled first.
1529
+ */
1530
+ async deleteRun(runId) {
1531
+ const payload = await this.sendNamespacedCorrelatedSessionRequest({
1532
+ message: { type: "runs.delete.request", runId },
1533
+ });
1534
+ if (!payload.runId) {
1535
+ throw new Error(payload.error ?? "Failed to delete the orchestration");
1536
+ }
1537
+ return payload.runId;
1538
+ }
1400
1539
  /** Orchestration: list the host's reusable graph templates. */
1401
1540
  async listOrchestrationGraphs() {
1402
1541
  const payload = await this.sendNamespacedCorrelatedSessionRequest({
@@ -1424,6 +1563,33 @@ export class DaemonClient {
1424
1563
  }
1425
1564
  return payload.deleted;
1426
1565
  }
1566
+ /** Orchestration: list the host's reusable prompt templates and snippets. */
1567
+ async listPromptTemplates() {
1568
+ const payload = await this.sendNamespacedCorrelatedSessionRequest({
1569
+ message: { type: "runs.templates.list.request" },
1570
+ });
1571
+ return payload.templates;
1572
+ }
1573
+ /** Orchestration: upsert a prompt template. Returns the persisted template. */
1574
+ async savePromptTemplate(template) {
1575
+ const payload = await this.sendNamespacedCorrelatedSessionRequest({
1576
+ message: { type: "runs.templates.save.request", template },
1577
+ });
1578
+ if (payload.error !== undefined || payload.template === undefined) {
1579
+ throw new Error(payload.error ?? "savePromptTemplate rejected");
1580
+ }
1581
+ return payload.template;
1582
+ }
1583
+ /** Orchestration: delete a prompt template (built-in starters refuse). */
1584
+ async deletePromptTemplate(templateId) {
1585
+ const payload = await this.sendNamespacedCorrelatedSessionRequest({
1586
+ message: { type: "runs.templates.delete.request", templateId },
1587
+ });
1588
+ if (payload.error !== undefined) {
1589
+ throw new Error(payload.error);
1590
+ }
1591
+ return payload.deleted;
1592
+ }
1427
1593
  /**
1428
1594
  * Orchestration: start (or draft) a user-initiated orchestration. Returns the
1429
1595
  * run id (graph flavor) and the orchestrator chat's agent id to navigate to.
@@ -1601,6 +1767,7 @@ export class DaemonClient {
1601
1767
  ? { providerId: input.providerId, providerHandleId: input.providerHandleId }
1602
1768
  : { provider: input.provider, sessionId: input.sessionId }),
1603
1769
  ...(input.cwd ? { cwd: input.cwd } : {}),
1770
+ ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
1604
1771
  ...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}),
1605
1772
  });
1606
1773
  const status = await this.sendRequest({
@@ -1723,6 +1890,7 @@ export class DaemonClient {
1723
1890
  ...(messageId ? { messageId } : {}),
1724
1891
  ...(options?.images ? { images: options.images } : {}),
1725
1892
  ...(options?.attachments ? { attachments: options.attachments } : {}),
1893
+ ...(options?.delivery ? { delivery: options.delivery } : {}),
1726
1894
  });
1727
1895
  const payload = await this.sendRequest({
1728
1896
  requestId,
@@ -1741,10 +1909,107 @@ export class DaemonClient {
1741
1909
  if (!payload.accepted) {
1742
1910
  throw new Error(payload.error ?? "sendAgentMessage rejected");
1743
1911
  }
1912
+ return {
1913
+ queued: payload.queued ?? false,
1914
+ queuedMessageId: payload.queuedMessageId ?? null,
1915
+ };
1744
1916
  }
1745
1917
  async sendMessage(agentId, text, options) {
1746
1918
  await this.sendAgentMessage(agentId, text, options);
1747
1919
  }
1920
+ /**
1921
+ * Pull one message back out of an agent's queue. Returns its text so the
1922
+ * caller can put it back in the composer, or null when the turn already
1923
+ * drained it. Requires `server_info.features.steerQueue`.
1924
+ */
1925
+ async removeQueuedAgentMessage(agentId, messageId) {
1926
+ const requestId = this.createRequestId();
1927
+ const message = SessionInboundMessageSchema.parse({
1928
+ type: "agent.queue.remove.request",
1929
+ requestId,
1930
+ agentId,
1931
+ messageId,
1932
+ });
1933
+ const payload = await this.sendRequest({
1934
+ requestId,
1935
+ message,
1936
+ options: { skipQueue: true },
1937
+ select: (msg) => {
1938
+ if (msg.type !== "agent.queue.remove.response") {
1939
+ return null;
1940
+ }
1941
+ if (msg.payload.requestId !== requestId) {
1942
+ return null;
1943
+ }
1944
+ return msg.payload;
1945
+ },
1946
+ });
1947
+ if (payload.error) {
1948
+ throw new Error(payload.error);
1949
+ }
1950
+ return payload.removed;
1951
+ }
1952
+ /**
1953
+ * Move one queued message to a new position. Resolves false when the entry
1954
+ * was already drained or was already there — the authoritative order arrives
1955
+ * on the agent snapshot either way. Requires
1956
+ * `server_info.features.steerQueueReorder`.
1957
+ */
1958
+ async reorderQueuedAgentMessage(agentId, messageId, toIndex) {
1959
+ const requestId = this.createRequestId();
1960
+ const message = SessionInboundMessageSchema.parse({
1961
+ type: "agent.queue.reorder.request",
1962
+ requestId,
1963
+ agentId,
1964
+ messageId,
1965
+ toIndex,
1966
+ });
1967
+ const payload = await this.sendRequest({
1968
+ requestId,
1969
+ message,
1970
+ options: { skipQueue: true },
1971
+ select: (msg) => {
1972
+ if (msg.type !== "agent.queue.reorder.response") {
1973
+ return null;
1974
+ }
1975
+ if (msg.payload.requestId !== requestId) {
1976
+ return null;
1977
+ }
1978
+ return msg.payload;
1979
+ },
1980
+ });
1981
+ if (payload.error) {
1982
+ throw new Error(payload.error);
1983
+ }
1984
+ return payload.moved;
1985
+ }
1986
+ /** Drop every message queued behind an agent's current turn. */
1987
+ async clearAgentQueue(agentId) {
1988
+ const requestId = this.createRequestId();
1989
+ const message = SessionInboundMessageSchema.parse({
1990
+ type: "agent.queue.clear.request",
1991
+ requestId,
1992
+ agentId,
1993
+ });
1994
+ const payload = await this.sendRequest({
1995
+ requestId,
1996
+ message,
1997
+ options: { skipQueue: true },
1998
+ select: (msg) => {
1999
+ if (msg.type !== "agent.queue.clear.response") {
2000
+ return null;
2001
+ }
2002
+ if (msg.payload.requestId !== requestId) {
2003
+ return null;
2004
+ }
2005
+ return msg.payload;
2006
+ },
2007
+ });
2008
+ if (payload.error) {
2009
+ throw new Error(payload.error);
2010
+ }
2011
+ return payload.clearedCount;
2012
+ }
1748
2013
  async rewindAgent(agentId, messageId, mode) {
1749
2014
  const requestId = this.createRequestId();
1750
2015
  const message = SessionInboundMessageSchema.parse({
@@ -2900,6 +3165,62 @@ export class DaemonClient {
2900
3165
  });
2901
3166
  return payload.result;
2902
3167
  }
3168
+ /** Create an empty file or a directory. Never overwrites — see FileCreateResultSchema. */
3169
+ async createFileEntry(options) {
3170
+ const payload = await this.sendCorrelatedSessionRequest({
3171
+ requestId: options.requestId,
3172
+ message: {
3173
+ type: "file.create.request",
3174
+ cwd: options.cwd,
3175
+ path: options.path,
3176
+ kind: options.kind,
3177
+ },
3178
+ responseType: "file.create.response",
3179
+ });
3180
+ return payload.result;
3181
+ }
3182
+ /** Permanent delete — an unlink, not a move to any trash. */
3183
+ async deleteFileEntry(options) {
3184
+ const payload = await this.sendCorrelatedSessionRequest({
3185
+ requestId: options.requestId,
3186
+ message: {
3187
+ type: "file.delete.request",
3188
+ cwd: options.cwd,
3189
+ path: options.path,
3190
+ recursive: options.recursive,
3191
+ },
3192
+ responseType: "file.delete.response",
3193
+ });
3194
+ return payload.result;
3195
+ }
3196
+ /** Rename, which is also move. Never clobbers an occupied destination. */
3197
+ async renameFileEntry(options) {
3198
+ const payload = await this.sendCorrelatedSessionRequest({
3199
+ requestId: options.requestId,
3200
+ message: {
3201
+ type: "file.rename.request",
3202
+ cwd: options.cwd,
3203
+ path: options.path,
3204
+ newPath: options.newPath,
3205
+ },
3206
+ responseType: "file.rename.response",
3207
+ });
3208
+ return payload.result;
3209
+ }
3210
+ async refineFile(options) {
3211
+ const payload = await this.sendCorrelatedSessionRequest({
3212
+ requestId: options.requestId,
3213
+ message: {
3214
+ type: "file.refine.request",
3215
+ cwd: options.cwd,
3216
+ documents: options.documents,
3217
+ references: options.references,
3218
+ instruction: options.instruction,
3219
+ },
3220
+ responseType: "file.refine.response",
3221
+ });
3222
+ return payload.result;
3223
+ }
2903
3224
  /**
2904
3225
  * Project-wide search. Per-file results stream through onFileResult (the
2905
3226
  * daemon emits them in order, before the summary response resolves); the
@@ -2955,6 +3276,265 @@ export class DaemonClient {
2955
3276
  }
2956
3277
  return payload.locations;
2957
3278
  }
3279
+ /**
3280
+ * Language-server-backed go-to-definition. Unlike `findCodeSymbols` this resolves the
3281
+ * reference *at a position*, so multiple results mean real overloads or
3282
+ * implementations rather than "two files happen to use this name".
3283
+ *
3284
+ * Line and column are 1-based. Returns the whole payload, not just the locations,
3285
+ * because `indexing` and `unavailable` are answers the caller must show differently
3286
+ * from an empty result.
3287
+ */
3288
+ async findCodeDefinition(input, requestId) {
3289
+ const payload = await this.sendCorrelatedSessionRequest({
3290
+ requestId,
3291
+ message: {
3292
+ type: "code.definition.request",
3293
+ cwd: input.cwd,
3294
+ path: input.path,
3295
+ line: input.line,
3296
+ column: input.column,
3297
+ },
3298
+ responseType: "code.definition.response",
3299
+ });
3300
+ return { status: payload.status, locations: payload.locations, error: payload.error };
3301
+ }
3302
+ /**
3303
+ * Mirror the editor's current buffer to the daemon so definitions resolve against
3304
+ * unsaved edits. Debounced by the caller — this is not a per-keystroke RPC.
3305
+ */
3306
+ async syncCodeDocument(cwd, path, text, requestId) {
3307
+ const payload = await this.sendCorrelatedSessionRequest({
3308
+ requestId,
3309
+ message: { type: "code.document.sync.request", cwd, path, text },
3310
+ responseType: "code.document.sync.response",
3311
+ });
3312
+ if (payload.error) {
3313
+ throw new Error(payload.error);
3314
+ }
3315
+ }
3316
+ /** Release the daemon-side mirror when a file tab closes. */
3317
+ async closeCodeDocument(cwd, path, requestId) {
3318
+ const payload = await this.sendCorrelatedSessionRequest({
3319
+ requestId,
3320
+ message: { type: "code.document.close.request", cwd, path },
3321
+ responseType: "code.document.close.response",
3322
+ });
3323
+ if (payload.error) {
3324
+ throw new Error(payload.error);
3325
+ }
3326
+ }
3327
+ /**
3328
+ * The language server's own explanation of the symbol at a position. Returns the
3329
+ * whole payload: `indexing` and `unavailable` read differently to a user than "the
3330
+ * server had nothing to say", which is `ok` with a null `markdown`.
3331
+ */
3332
+ async getCodeHover(input, requestId) {
3333
+ const payload = await this.sendCorrelatedSessionRequest({
3334
+ requestId,
3335
+ message: {
3336
+ type: "code.hover.request",
3337
+ cwd: input.cwd,
3338
+ path: input.path,
3339
+ line: input.line,
3340
+ column: input.column,
3341
+ },
3342
+ responseType: "code.hover.response",
3343
+ });
3344
+ return {
3345
+ status: payload.status,
3346
+ markdown: payload.markdown,
3347
+ range: payload.range,
3348
+ serverId: payload.serverId,
3349
+ error: payload.error,
3350
+ };
3351
+ }
3352
+ /** Every reference to the symbol at a position, for the references results tab. */
3353
+ async findCodeReferences(input, requestId) {
3354
+ const payload = await this.sendCorrelatedSessionRequest({
3355
+ requestId,
3356
+ message: {
3357
+ type: "code.references.request",
3358
+ cwd: input.cwd,
3359
+ path: input.path,
3360
+ line: input.line,
3361
+ column: input.column,
3362
+ },
3363
+ responseType: "code.references.response",
3364
+ });
3365
+ return { status: payload.status, locations: payload.locations, error: payload.error };
3366
+ }
3367
+ /**
3368
+ * A rename **dry run** — every edit it would make, and nothing written. The client
3369
+ * puts this in front of the user as a job to audit before applying.
3370
+ */
3371
+ async previewCodeRename(input, requestId) {
3372
+ const payload = await this.sendCorrelatedSessionRequest({
3373
+ requestId,
3374
+ message: {
3375
+ type: "code.rename.preview.request",
3376
+ cwd: input.cwd,
3377
+ path: input.path,
3378
+ line: input.line,
3379
+ column: input.column,
3380
+ newName: input.newName,
3381
+ },
3382
+ responseType: "code.rename.preview.response",
3383
+ });
3384
+ return {
3385
+ status: payload.status,
3386
+ files: payload.files,
3387
+ fileCount: payload.fileCount,
3388
+ editCount: payload.editCount,
3389
+ planId: payload.planId,
3390
+ error: payload.error,
3391
+ };
3392
+ }
3393
+ /**
3394
+ * Execute a rename the user audited. Sends the and NOT the edits: the daemon
3395
+ * recomputes the plan and refuses unless the identity still matches, which is what keeps
3396
+ * this from being an arbitrary-write RPC and what makes "what you approved is what
3397
+ * happens" enforceable rather than merely intended.
3398
+ */
3399
+ async applyCodeRename(input, requestId) {
3400
+ const payload = await this.sendCorrelatedSessionRequest({
3401
+ requestId,
3402
+ message: {
3403
+ type: "code.rename.apply.request",
3404
+ cwd: input.cwd,
3405
+ path: input.path,
3406
+ line: input.line,
3407
+ column: input.column,
3408
+ newName: input.newName,
3409
+ planId: input.planId,
3410
+ },
3411
+ responseType: "code.rename.apply.response",
3412
+ });
3413
+ return {
3414
+ status: payload.status,
3415
+ runId: payload.runId,
3416
+ files: payload.files,
3417
+ appliedFiles: payload.appliedFiles,
3418
+ appliedEdits: payload.appliedEdits,
3419
+ skippedEdits: payload.skippedEdits,
3420
+ complete: payload.complete,
3421
+ error: payload.error,
3422
+ };
3423
+ }
3424
+ /**
3425
+ * Take a rename run back. Sends only the run id: the daemon holds the before-images, and
3426
+ * restores a file only if it still holds exactly what the run wrote.
3427
+ */
3428
+ async undoCodeRename(cwd, runId, requestId) {
3429
+ const payload = await this.sendCorrelatedSessionRequest({
3430
+ requestId,
3431
+ message: { type: "code.rename.undo.request", cwd, runId },
3432
+ responseType: "code.rename.undo.response",
3433
+ });
3434
+ return {
3435
+ status: payload.status,
3436
+ files: payload.files,
3437
+ restoredFiles: payload.restoredFiles,
3438
+ complete: payload.complete,
3439
+ error: payload.error,
3440
+ };
3441
+ }
3442
+ /**
3443
+ * Live language-server state for the Daemon → Code screen: what this host can
3444
+ * supply, and what is running now. `cwd` scopes availability, since a server can
3445
+ * be present in one workspace's `node_modules` and absent in another's.
3446
+ */
3447
+ async listLspServers(cwd, requestId) {
3448
+ const payload = await this.sendCorrelatedSessionRequest({
3449
+ requestId,
3450
+ message: { type: "lsp.servers.list.request", cwd },
3451
+ responseType: "lsp.servers.list.response",
3452
+ });
3453
+ if (payload.error) {
3454
+ throw new Error(payload.error);
3455
+ }
3456
+ return { languages: payload.languages, running: payload.running };
3457
+ }
3458
+ /** Stop one running language server. */
3459
+ async stopLspServer(rootPath, serverId, requestId) {
3460
+ const payload = await this.sendCorrelatedSessionRequest({
3461
+ requestId,
3462
+ message: { type: "lsp.server.stop.request", rootPath, serverId },
3463
+ responseType: "lsp.server.stop.response",
3464
+ });
3465
+ if (payload.error) {
3466
+ throw new Error(payload.error);
3467
+ }
3468
+ }
3469
+ /**
3470
+ * Solutions in a workspace, which is what decides whether the Files tab shows a view switcher
3471
+ * at all.
3472
+ *
3473
+ * Never throws and never carries an error the caller has to render. A workspace with no
3474
+ * solution, a host with no .NET SDK, and a host with the feature switched off all answer with an
3475
+ * empty list, so the caller has one silent case — "no switcher" — rather than four states.
3476
+ */
3477
+ async listSolutions(cwd, requestId) {
3478
+ const payload = await this.sendCorrelatedSessionRequest({
3479
+ requestId,
3480
+ message: { type: "code.solution.list.request", cwd },
3481
+ responseType: "code.solution.list.response",
3482
+ });
3483
+ return payload.solutions;
3484
+ }
3485
+ /** One solution's organisation: folders, the projects inside them, configurations. */
3486
+ async getSolutionTree(input, requestId) {
3487
+ const payload = await this.sendCorrelatedSessionRequest({
3488
+ requestId,
3489
+ message: {
3490
+ type: "code.solution.get_tree.request",
3491
+ cwd: input.cwd,
3492
+ solutionPath: input.solutionPath,
3493
+ },
3494
+ responseType: "code.solution.get_tree.response",
3495
+ });
3496
+ if (payload.error) {
3497
+ throw new Error(payload.error);
3498
+ }
3499
+ return {
3500
+ solutionPath: payload.solutionPath,
3501
+ name: payload.name,
3502
+ format: payload.format,
3503
+ folders: payload.folders,
3504
+ projects: payload.projects,
3505
+ buildTypes: payload.buildTypes,
3506
+ platforms: payload.platforms,
3507
+ };
3508
+ }
3509
+ /**
3510
+ * One project's evaluated file membership, fetched on expand.
3511
+ *
3512
+ * A `failed` status is a normal answer, not an exception: the daemon carries MSBuild's own
3513
+ * message for a project it refused, and one bad project must not blank the tree.
3514
+ */
3515
+ async loadSolutionProject(input, requestId) {
3516
+ const payload = await this.sendCorrelatedSessionRequest({
3517
+ requestId,
3518
+ message: {
3519
+ type: "code.solution.load_project.request",
3520
+ cwd: input.cwd,
3521
+ solutionPath: input.solutionPath,
3522
+ projectPath: input.projectPath,
3523
+ },
3524
+ responseType: "code.solution.load_project.response",
3525
+ });
3526
+ return {
3527
+ projectPath: payload.projectPath,
3528
+ status: payload.status,
3529
+ nodes: payload.nodes,
3530
+ projectReferences: payload.projectReferences,
3531
+ packageReferences: payload.packageReferences,
3532
+ targetFrameworks: payload.targetFrameworks,
3533
+ outputType: payload.outputType,
3534
+ isSdkStyle: payload.isSdkStyle,
3535
+ error: payload.error,
3536
+ };
3537
+ }
2958
3538
  /** Definition symbols for a single file (document outline). */
2959
3539
  async getCodeOutline(cwd, path, requestId) {
2960
3540
  const payload = await this.sendCorrelatedSessionRequest({
@@ -3107,10 +3687,94 @@ export class DaemonClient {
3107
3687
  workspaceId: input.workspaceId,
3108
3688
  ...(input.provider ? { provider: input.provider } : {}),
3109
3689
  ...(typeof input.windowTokens === "number" ? { windowTokens: input.windowTokens } : {}),
3690
+ ...(input.personalityId ? { personalityId: input.personalityId } : {}),
3110
3691
  },
3111
3692
  responseType: "context.report.get.response",
3112
3693
  });
3113
3694
  }
3695
+ /**
3696
+ * The assembled prompt, for reading. Takes the same what-if inputs as the
3697
+ * report so the text on screen always matches the numbers beside it.
3698
+ */
3699
+ async requestContextPromptPreview(input, requestId) {
3700
+ return this.sendCorrelatedSessionRequest({
3701
+ requestId,
3702
+ message: {
3703
+ type: "context.prompt.preview.get.request",
3704
+ workspaceId: input.workspaceId,
3705
+ ...(input.provider ? { provider: input.provider } : {}),
3706
+ ...(typeof input.windowTokens === "number" ? { windowTokens: input.windowTokens } : {}),
3707
+ ...(input.personalityId ? { personalityId: input.personalityId } : {}),
3708
+ ...(input.category ? { category: input.category } : {}),
3709
+ },
3710
+ responseType: "context.prompt.preview.get.response",
3711
+ });
3712
+ }
3713
+ // ============================================================================
3714
+ // Personality memory
3715
+ // ============================================================================
3716
+ /**
3717
+ * A personality's accrued lessons plus the EXACT brief the daemon would inject
3718
+ * for `projectRoot`. The brief is returned rather than rebuilt client-side
3719
+ * because memory is only trustworthy if what you are shown is what is sent.
3720
+ */
3721
+ async listPersonalityMemory(input, requestId) {
3722
+ return this.sendNamespacedCorrelatedSessionRequest({
3723
+ requestId,
3724
+ message: {
3725
+ type: "personality.memory.list.request",
3726
+ personalityId: input.personalityId,
3727
+ ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
3728
+ ...(input.projectRoot ? { projectRoot: input.projectRoot } : {}),
3729
+ },
3730
+ });
3731
+ }
3732
+ /**
3733
+ * Add (no `entryId`), edit, or forget (`drop`) one lesson.
3734
+ *
3735
+ * Pass `workspaceId` whenever the write may be project-scoped: the daemon
3736
+ * binds the entry to the repo root that workspace resolves to, and an entry
3737
+ * scoped to "project" with no root is filtered out of every brief — stored,
3738
+ * listed, and never sent.
3739
+ */
3740
+ async updatePersonalityMemory(input, requestId) {
3741
+ return this.sendNamespacedCorrelatedSessionRequest({
3742
+ requestId,
3743
+ message: {
3744
+ type: "personality.memory.update.request",
3745
+ personalityId: input.personalityId,
3746
+ ...(input.entryId ? { entryId: input.entryId } : {}),
3747
+ ...(input.text !== undefined ? { text: input.text } : {}),
3748
+ ...(input.scope ? { scope: input.scope } : {}),
3749
+ ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
3750
+ ...(input.projectRoot ? { projectRoot: input.projectRoot } : {}),
3751
+ ...(input.drop ? { drop: true } : {}),
3752
+ },
3753
+ });
3754
+ }
3755
+ /**
3756
+ * Resolve a deleted personality's lessons: move them to another personality or
3757
+ * discard them. Called BEFORE the roster write, so a failure leaves both the
3758
+ * personality and its memory intact.
3759
+ */
3760
+ async transferPersonalityMemory(input, requestId) {
3761
+ return this.sendNamespacedCorrelatedSessionRequest({
3762
+ requestId,
3763
+ message: {
3764
+ type: "personality.memory.transfer.request",
3765
+ fromPersonalityId: input.fromPersonalityId,
3766
+ ...(input.toPersonalityId ? { toPersonalityId: input.toPersonalityId } : {}),
3767
+ mode: input.mode,
3768
+ },
3769
+ });
3770
+ }
3771
+ /** Per-personality lesson counts, for the accrual indicator and the selector. */
3772
+ async getPersonalityMemoryStats(requestId) {
3773
+ return this.sendNamespacedCorrelatedSessionRequest({
3774
+ requestId,
3775
+ message: { type: "personality.memory.stats.request" },
3776
+ });
3777
+ }
3114
3778
  /** Rewrites one reference between "always loaded" and "link only". */
3115
3779
  async requestContextEdgeConvert(input, requestId) {
3116
3780
  return this.sendCorrelatedSessionRequest({
@@ -3126,6 +3790,17 @@ export class DaemonClient {
3126
3790
  responseType: "context.edge.convert.response",
3127
3791
  });
3128
3792
  }
3793
+ /** Deletes every mechanically-fixable finding's range in one pass. */
3794
+ async requestContextFindingsFix(input, requestId) {
3795
+ return this.sendNamespacedCorrelatedSessionRequest({
3796
+ requestId,
3797
+ message: {
3798
+ type: "context.findings.fix.request",
3799
+ workspaceId: input.workspaceId,
3800
+ findings: input.findings,
3801
+ },
3802
+ });
3803
+ }
3129
3804
  // ============================================================================
3130
3805
  // Provider Models / Commands
3131
3806
  // ============================================================================
@@ -4098,6 +4773,18 @@ export class DaemonClient {
4098
4773
  getLastServerInfoMessage() {
4099
4774
  return this.lastServerInfoMessage;
4100
4775
  }
4776
+ /**
4777
+ * Session totals for inbound daemon traffic, including the main-thread time
4778
+ * spent handling it. Null when runtime metrics are disabled for this client.
4779
+ * Read by the app's resource monitor — the wire is a first-class suspect when
4780
+ * the UI thread degrades, so it has to be measurable rather than inferred.
4781
+ */
4782
+ getTrafficTotals() {
4783
+ return this.runtimeMetrics?.getTrafficTotals() ?? null;
4784
+ }
4785
+ getTrafficHotspots(limit) {
4786
+ return this.runtimeMetrics?.getTrafficHotspots(limit) ?? [];
4787
+ }
4101
4788
  resolveTransportUrlForAttempt() {
4102
4789
  return this.config.url;
4103
4790
  }
@@ -4449,6 +5136,12 @@ export class DaemonClient {
4449
5136
  if (consumerMessage.type === "terminal_stream_exit") {
4450
5137
  this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
4451
5138
  }
5139
+ // Scaffold progress is advisory and scoped to one in-flight request, so it
5140
+ // is delivered to that request's own listener rather than the global
5141
+ // DaemonEvent stream every consumer would then have to ignore.
5142
+ if (consumerMessage.type === "project.scaffold.progress") {
5143
+ this.scaffoldProgressListeners.get(consumerMessage.payload.requestId)?.(consumerMessage.payload);
5144
+ }
4452
5145
  if (this.rawMessageListeners.size > 0) {
4453
5146
  for (const handler of this.rawMessageListeners) {
4454
5147
  try {