@rho-workspace/rho 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/dist/agent-connect.d.ts +42 -0
  2. package/dist/agent-connect.js +68 -0
  3. package/dist/answer-board.js +3 -3
  4. package/dist/bridge.js +6 -1
  5. package/dist/browser-access.d.ts +1 -0
  6. package/dist/browser-access.js +5 -3
  7. package/dist/capabilities.d.ts +21 -5
  8. package/dist/capabilities.js +6 -4
  9. package/dist/cli.js +113 -18
  10. package/dist/composition.d.ts +1 -1
  11. package/dist/composition.js +13 -3
  12. package/dist/connect-cli.d.ts +50 -0
  13. package/dist/connect-cli.js +128 -0
  14. package/dist/contract.generated.d.ts +1437 -381
  15. package/dist/contract.generated.js +1 -1
  16. package/dist/contracts/owners.generated.js +1160 -267
  17. package/dist/conversation-presentation.d.ts +9 -2
  18. package/dist/conversation-presentation.js +3 -1
  19. package/dist/conversations.d.ts +306 -7
  20. package/dist/conversations.js +208 -22
  21. package/dist/discovery.js +3 -3
  22. package/dist/extension-contract.d.ts +19 -0
  23. package/dist/extension-contract.js +15 -6
  24. package/dist/extension-markets.d.ts +74 -0
  25. package/dist/extension-markets.js +166 -0
  26. package/dist/extensions.d.ts +29 -1
  27. package/dist/extensions.js +13 -4
  28. package/dist/external-work.d.ts +22 -0
  29. package/dist/external-work.js +91 -0
  30. package/dist/plugin-cli.js +75 -3
  31. package/dist/plugin-inventory.d.ts +56 -0
  32. package/dist/plugin-inventory.js +41 -0
  33. package/dist/plugin-inventory.json +1581 -102
  34. package/dist/plugin-presentation.d.ts +105 -2
  35. package/dist/plugin-presentation.js +59 -5
  36. package/dist/plugins.d.ts +14 -3
  37. package/dist/plugins.js +12 -6
  38. package/dist/project-notes.js +1 -1
  39. package/dist/protocol.d.ts +7 -0
  40. package/dist/protocol.js +40 -15
  41. package/dist/replay.js +4 -0
  42. package/dist/response-contracts.js +26 -11
  43. package/dist/running.d.ts +10 -0
  44. package/dist/running.js +45 -0
  45. package/dist/server.d.ts +4 -10
  46. package/dist/server.js +140 -14
  47. package/dist/state.d.ts +8 -0
  48. package/dist/state.js +8 -0
  49. package/dist/studio-layout.js +3 -3
  50. package/dist/task-frame.d.ts +1 -1
  51. package/dist/timeline-events.d.ts +1 -0
  52. package/dist/timeline-events.js +8 -1
  53. package/dist/ui-context.d.ts +73 -0
  54. package/dist/ui-context.js +98 -0
  55. package/dist/ui-contract.d.ts +23 -0
  56. package/dist/web/.vite/manifest.json +135 -76
  57. package/dist/web/assets/answer-board-CeYS8k8y.js +1 -0
  58. package/dist/web/assets/choices-D8vGwsl2.js +1 -0
  59. package/dist/web/assets/{console-BSjLGe0T.js → console-Dyuo1q4N.js} +4 -4
  60. package/dist/web/assets/{dist-Tzoe_zm-.js → dist-CSiCyBml.js} +1 -1
  61. package/dist/web/assets/{dist-Cw8hRIlT.js → dist-D9FLb9Hm.js} +1 -1
  62. package/dist/web/assets/{dist-foH9IKc6.js → dist-FXbTBwML.js} +1 -1
  63. package/dist/web/assets/{dist-Buk0lwzI.js → dist-l3DUhbw5.js} +1 -1
  64. package/dist/web/assets/{explore-in-BVUy3ZW_.js → explore-in-BfSFGRh2.js} +1 -1
  65. package/dist/web/assets/graph-BU2_o5uA.js +2 -0
  66. package/dist/web/assets/index-C16mXVvk.css +1 -0
  67. package/dist/web/assets/index-CWg9XYXx.js +952 -0
  68. package/dist/web/assets/markdown.reader-Ct2-e2Zt.png +0 -0
  69. package/dist/web/assets/{notes-meaning-pVbbQUGP.js → notes-meaning-T2m5zQfv.js} +1 -1
  70. package/dist/web/assets/overview-PzfLJCo-.js +6 -0
  71. package/dist/web/assets/preview-BguEdsza.js +1 -0
  72. package/dist/web/assets/previews-pWNik-2g.js +3 -0
  73. package/dist/web/assets/{review-CMxQlQ-C.js → review-Dy_85lcH.js} +1 -1
  74. package/dist/web/assets/rho.editor-CCJeyHma.png +0 -0
  75. package/dist/web/assets/rho.flow-WkMj6fRS.png +0 -0
  76. package/dist/web/assets/rho.media-Cc6amodO.png +0 -0
  77. package/dist/web/assets/rho.notebook-6vsZKGDq.png +0 -0
  78. package/dist/web/assets/rho.object-BGoeXjte.png +0 -0
  79. package/dist/web/assets/rho.plots-yIiISCkb.png +0 -0
  80. package/dist/web/assets/rho.scatter-DwHxesCs.png +0 -0
  81. package/dist/web/assets/rho.servers-DKf_mnNq.png +0 -0
  82. package/dist/web/assets/rho.table-DWOGaWDa.png +0 -0
  83. package/dist/web/assets/rho.variables-CKcWczhQ.png +0 -0
  84. package/dist/web/assets/{skills-BFNSyrnD.js → skills-Dyz7mBlc.js} +1 -1
  85. package/dist/web/assets/{skills-work-BGPSnF_I.js → skills-work-BEGqlCkx.js} +1 -1
  86. package/dist/web/assets/studio-process-D-ykbC4D.js +2 -0
  87. package/dist/web/assets/timeline-events-DKTho9fU.js +1 -0
  88. package/dist/web/assets/work-turns-DSabCogR.js +1 -0
  89. package/dist/web/assets/{workspace-CWlkjLNx.js → workspace-DfjBL--r.js} +2 -2
  90. package/dist/web/composition.json +135 -80
  91. package/dist/web/index.html +3 -3
  92. package/dist/web/renderer.json +1 -1
  93. package/dist/web-assets.js +2 -2
  94. package/dist/work-graph.d.ts +11 -1
  95. package/dist/work-graph.js +16 -5
  96. package/dist/workspace.d.ts +2 -2
  97. package/dist/workspace.js +8 -7
  98. package/docs/FRONTEND.md +71 -21
  99. package/docs/HEADLESS.md +127 -24
  100. package/docs/THIRD-PARTY-PLUGINS.md +75 -3
  101. package/node_modules/@rho-workspace/plugin-agent/README.md +34 -0
  102. package/node_modules/@rho-workspace/plugin-agent/dist/acp-command.d.ts +32 -0
  103. package/node_modules/@rho-workspace/plugin-agent/dist/acp-command.js +54 -11
  104. package/node_modules/@rho-workspace/plugin-agent/dist/acp-conversation.d.ts +1 -0
  105. package/node_modules/@rho-workspace/plugin-agent/dist/acp-conversation.js +29 -3
  106. package/node_modules/@rho-workspace/plugin-agent/dist/external-session-contract.js +11 -5
  107. package/node_modules/@rho-workspace/plugin-agent/dist/external-session.d.ts +12 -1
  108. package/node_modules/@rho-workspace/plugin-agent/dist/index.d.ts +8 -0
  109. package/node_modules/@rho-workspace/plugin-agent/dist/index.js +3 -2
  110. package/node_modules/@rho-workspace/plugin-agent/dist/mcp-entry.d.ts +13 -0
  111. package/node_modules/@rho-workspace/plugin-agent/dist/mcp-entry.js +13 -0
  112. package/node_modules/@rho-workspace/plugin-agent/dist/mcp.js +16 -8
  113. package/node_modules/@rho-workspace/plugin-agent/dist/native-sessions.d.ts +70 -0
  114. package/node_modules/@rho-workspace/plugin-agent/dist/native-sessions.js +538 -0
  115. package/node_modules/@rho-workspace/plugin-agent/package.json +2 -1
  116. package/node_modules/@rho-workspace/plugin-figures/LICENSE +661 -0
  117. package/node_modules/@rho-workspace/plugin-figures/README.md +191 -0
  118. package/node_modules/@rho-workspace/plugin-figures/dist/index.d.ts +236 -0
  119. package/node_modules/@rho-workspace/plugin-figures/dist/index.js +165 -0
  120. package/node_modules/@rho-workspace/plugin-figures/dist/operations.d.ts +44 -0
  121. package/node_modules/@rho-workspace/plugin-figures/dist/operations.js +39 -0
  122. package/node_modules/@rho-workspace/plugin-figures/dist/presentation.d.ts +19 -0
  123. package/node_modules/@rho-workspace/plugin-figures/dist/presentation.js +26 -0
  124. package/node_modules/@rho-workspace/plugin-figures/dist/record.d.ts +188 -0
  125. package/node_modules/@rho-workspace/plugin-figures/dist/record.js +143 -0
  126. package/node_modules/@rho-workspace/plugin-figures/dist/ui-styles.d.ts +1 -0
  127. package/node_modules/@rho-workspace/plugin-figures/dist/ui-styles.js +68 -0
  128. package/node_modules/@rho-workspace/plugin-figures/dist/ui.d.ts +56 -0
  129. package/node_modules/@rho-workspace/plugin-figures/dist/ui.js +363 -0
  130. package/node_modules/@rho-workspace/plugin-figures/package.json +41 -0
  131. package/node_modules/@rho-workspace/plugin-figures/py/figures.py +856 -0
  132. package/node_modules/@rho-workspace/plugin-figures/r/figures.R +775 -0
  133. package/node_modules/@rho-workspace/plugin-kernel/README.md +27 -3
  134. package/node_modules/@rho-workspace/plugin-kernel/dist/contract.d.ts +12 -0
  135. package/node_modules/@rho-workspace/plugin-kernel/dist/contract.js +8 -1
  136. package/node_modules/@rho-workspace/plugin-kernel/dist/index.d.ts +61 -0
  137. package/node_modules/@rho-workspace/plugin-kernel/dist/index.js +125 -9
  138. package/node_modules/@rho-workspace/plugin-kernel/dist/session-scope.d.ts +14 -0
  139. package/node_modules/@rho-workspace/plugin-kernel/dist/session-scope.js +9 -0
  140. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-base.d.ts +23 -5
  141. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-notebook-work.js +1 -1
  142. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-notebook.d.ts +6 -4
  143. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-notebook.js +44 -17
  144. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-styles.js +6 -0
  145. package/node_modules/@rho-workspace/plugin-kernel/package.json +1 -1
  146. package/node_modules/@rho-workspace/plugin-kernel/runners/runner.R +144 -4
  147. package/node_modules/@rho-workspace/plugin-kernel/runners/runner.py +106 -1
  148. package/node_modules/@rho-workspace/plugin-tables/README.md +5 -0
  149. package/node_modules/@rho-workspace/plugin-tables/dist/index.d.ts +1 -0
  150. package/node_modules/@rho-workspace/plugin-tables/dist/index.js +19 -3
  151. package/node_modules/@rho-workspace/plugin-tables/dist/ui.js +1 -1
  152. package/node_modules/@rho-workspace/plugin-tables/package.json +1 -1
  153. package/node_modules/hono/dist/adapter/aws-lambda/handler.js +1 -1
  154. package/node_modules/hono/dist/cjs/adapter/aws-lambda/handler.js +1 -1
  155. package/node_modules/hono/dist/cjs/adapter/bun/serve-static.js +1 -1
  156. package/node_modules/hono/dist/cjs/adapter/bun/websocket.js +1 -1
  157. package/node_modules/hono/dist/cjs/client/fetch-result-please.js +1 -1
  158. package/node_modules/hono/dist/cjs/helper/accepts/accepts.js +2 -1
  159. package/node_modules/hono/dist/cjs/helper/ssg/ssg.js +5 -3
  160. package/node_modules/hono/dist/cjs/jsx/base.js +49 -30
  161. package/node_modules/hono/dist/cjs/jsx/components.js +2 -2
  162. package/node_modules/hono/dist/cjs/jsx/constants.js +2 -0
  163. package/node_modules/hono/dist/cjs/jsx/jsx-runtime.js +13 -17
  164. package/node_modules/hono/dist/cjs/middleware/etag/index.js +3 -1
  165. package/node_modules/hono/dist/cjs/middleware/pretty-json/index.js +1 -0
  166. package/node_modules/hono/dist/cjs/middleware/timing/timing.js +4 -3
  167. package/node_modules/hono/dist/cjs/utils/cookie.js +2 -2
  168. package/node_modules/hono/dist/cjs/utils/mime.js +1 -1
  169. package/node_modules/hono/dist/cjs/utils/url.js +1 -1
  170. package/node_modules/hono/dist/client/fetch-result-please.js +1 -1
  171. package/node_modules/hono/dist/helper/accepts/accepts.js +2 -1
  172. package/node_modules/hono/dist/helper/ssg/ssg.js +5 -3
  173. package/node_modules/hono/dist/jsx/base.js +49 -32
  174. package/node_modules/hono/dist/jsx/components.js +2 -2
  175. package/node_modules/hono/dist/jsx/constants.js +2 -1
  176. package/node_modules/hono/dist/jsx/jsx-runtime.js +15 -19
  177. package/node_modules/hono/dist/middleware/etag/index.js +3 -1
  178. package/node_modules/hono/dist/middleware/language/language.js +1 -1
  179. package/node_modules/hono/dist/middleware/pretty-json/index.js +1 -0
  180. package/node_modules/hono/dist/middleware/timing/timing.js +4 -3
  181. package/node_modules/hono/dist/utils/cookie.js +2 -2
  182. package/node_modules/hono/dist/utils/mime.js +1 -1
  183. package/node_modules/hono/dist/utils/url.js +1 -1
  184. package/node_modules/hono/package.json +1 -1
  185. package/node_modules/jose/dist/webapi/jwe/general/decrypt.js +3 -2
  186. package/node_modules/jose/dist/webapi/jwks/remote.js +1 -1
  187. package/node_modules/jose/dist/webapi/jws/general/verify.js +2 -8
  188. package/node_modules/jose/dist/webapi/lib/jwe_decrypt.js +11 -8
  189. package/node_modules/jose/dist/webapi/lib/key_management.js +0 -1
  190. package/node_modules/jose/package.json +1 -1
  191. package/package.json +6 -4
  192. package/protocol/index.json +106 -64
  193. package/protocol/operations/agent.sessions.list.json +57 -3
  194. package/protocol/operations/agent.sessions.read.json +61 -4
  195. package/protocol/operations/capability.list.json +382 -6
  196. package/protocol/operations/composition.apply.json +10 -2
  197. package/protocol/operations/composition.delete.json +845 -0
  198. package/protocol/operations/composition.read.json +10 -2
  199. package/protocol/operations/composition.undo.json +10 -2
  200. package/protocol/operations/conversation.agents.add.json +346 -0
  201. package/protocol/operations/conversation.agents.remove.json +340 -0
  202. package/protocol/operations/conversation.configure.json +29 -0
  203. package/protocol/operations/conversation.decide.json +225 -0
  204. package/protocol/operations/conversation.list.json +29 -0
  205. package/protocol/operations/conversation.permission.json +225 -0
  206. package/protocol/operations/conversation.read.json +225 -0
  207. package/protocol/operations/conversation.restore.json +29 -0
  208. package/protocol/operations/conversation.send.json +294 -0
  209. package/protocol/operations/conversation.settings.check.json +226 -416
  210. package/protocol/operations/conversation.settings.read.json +224 -411
  211. package/protocol/operations/conversation.settings.update.json +248 -475
  212. package/protocol/operations/conversation.start.json +313 -6
  213. package/protocol/operations/conversation.stop.json +225 -0
  214. package/protocol/operations/conversation.trash.json +29 -0
  215. package/protocol/operations/extension.describe.json +39 -0
  216. package/protocol/operations/extension.install.json +62 -0
  217. package/protocol/operations/extension.list.json +164 -1
  218. package/protocol/operations/extension.market.change.json +210 -0
  219. package/protocol/operations/figure.read.json +265 -0
  220. package/protocol/operations/figure.select.json +157 -0
  221. package/protocol/operations/kernel.interrupt.json +119 -0
  222. package/protocol/operations/kernel.read.json +119 -0
  223. package/protocol/operations/kernel.run.json +119 -0
  224. package/protocol/operations/observation.snapshot.json +445 -8
  225. package/protocol/operations/project.openFile.json +10 -2
  226. package/protocol/operations/session.grant.json +4 -0
  227. package/protocol/operations/session.instructions.json +43 -0
  228. package/protocol/operations/shared.json +1 -0
  229. package/protocol/operations/view.close.json +10 -2
  230. package/protocol/operations/view.protection.set.json +10 -2
  231. package/vendor/composition.json +174 -109
  232. package/vendor/rho-workspace-plugin-agent-0.4.33.tgz +0 -0
  233. package/vendor/rho-workspace-plugin-figures-0.5.1.tgz +0 -0
  234. package/vendor/rho-workspace-plugin-kernel-0.5.13.tgz +0 -0
  235. package/vendor/rho-workspace-plugin-tables-0.5.2.tgz +0 -0
  236. package/web/theme.css +17 -0
  237. package/dist/web/assets/answer-board-CBkGbSLD.js +0 -1
  238. package/dist/web/assets/canvas-D8rDdfDz.js +0 -1
  239. package/dist/web/assets/choices-b0XhrGQV.js +0 -1
  240. package/dist/web/assets/graph-1OdIJ9l7.js +0 -2
  241. package/dist/web/assets/index-CsC3sTRq.css +0 -1
  242. package/dist/web/assets/index-CxmItXqa.js +0 -891
  243. package/dist/web/assets/overview-B9UtVJ32.js +0 -6
  244. package/dist/web/assets/preview-CksD_5KS.js +0 -1
  245. package/dist/web/assets/studio-process-CsRoTHOh.js +0 -2
  246. package/dist/web/assets/timeline-events-BbuuFWHV.js +0 -1
  247. package/dist/web/assets/work-turns-BvJlIMmA.js +0 -1
  248. package/vendor/rho-workspace-plugin-agent-0.4.29.tgz +0 -0
  249. package/vendor/rho-workspace-plugin-kernel-0.5.5.tgz +0 -0
  250. package/vendor/rho-workspace-plugin-tables-0.5.0.tgz +0 -0
@@ -1,5 +1,6 @@
1
1
  import { projectPath, commandFiles, buildWorkGraph } from './work-graph.js';
2
2
  import { notesInstructions, projectNotesPath } from './project-notes.js';
3
+ import { uiContextPrompt } from './ui-context.js';
3
4
  import { addSpend, describeLimits, describable, fileFacts, figureContext, raster } from './file-describer.js';
4
5
  import { agentTurnLimit, displayCommand, toolActivity } from './conversation-presentation.js';
5
6
  import { privateEvidencePath } from './evidence-capture.js';
@@ -8,6 +9,7 @@ import path from 'node:path';
8
9
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import { atomicJson, canonical, digest, Fault, now, readJson, requireThat } from '@rho-workspace/plugin-contracts';
12
+ import { acpEnvironment, installedAcpAgents, knownAcpAgent, locate, missingClient } from '@rho-workspace/plugin-agent/acp-command';
11
13
  // Rho holds user messages and their original identities. The selected Agent plugin
12
14
  // owns the connection, native session and protocol. Reads never start a driver.
13
15
  // A new Claude Code session opens in Manual mode unless the person's Claude settings name another.
@@ -15,6 +17,9 @@ import { atomicJson, canonical, digest, Fault, now, readJson, requireThat } from
15
17
  // settings stays, and a model without Auto falls back to Accept edits with the adapter's warning.
16
18
  const claudePreferred = { mode: { value: 'auto', replaces: ['default'] } };
17
19
  export const conversationLimits = {
20
+ // Per-message interface observations: omit and count extra views; refuse an
21
+ // observation still larger than the byte budget before sending the message.
22
+ maxUiContextBytes: 16384, maxUiContextViews: 32,
18
23
  // Conversations kept per project, not counting the trash; starting or restoring another is
19
24
  // refused. The trash keeps as many again; past that, moving one there is refused.
20
25
  maxConversations: 64,
@@ -55,6 +60,9 @@ export const conversationLimits = {
55
60
  // Settings chosen for one Agent or one conversation, and the length of one chosen value;
56
61
  // more are refused.
57
62
  maxChoices: 12, maxChoiceChars: 512,
63
+ // ACP Agents the person adds in Rho, kept in this service's Agent settings and offered in every
64
+ // project; adding another is refused until one is removed.
65
+ maxAddedAgents: 16,
58
66
  // Files compared on disk before and after a message, to record which changed during it; past
59
67
  // this many the comparison stops and the record says it is partial. Changes kept per message;
60
68
  // more are counted.
@@ -127,7 +135,13 @@ function compare(before, after, limit) {
127
135
  changed.sort((a, b) => (a.modifiedAt ?? '\uffff').localeCompare(b.modifiedAt ?? '\uffff') || a.path.localeCompare(b.path));
128
136
  return { changed: changed.slice(0, limit), omitted: Math.max(0, changed.length - limit), partial };
129
137
  }
130
- const backends = ['claude-acp', 'codex-acp', 'acp'];
138
+ const builtIn = ['claude-acp', 'codex-acp', 'acp'];
139
+ const addedName = /^acp:[a-z0-9][a-z0-9-]{0,39}$/;
140
+ const backendName = (value) => typeof value === 'string' && (builtIn.includes(value) || addedName.test(value));
141
+ const generic = (backend) => backend === 'acp' || !!backend?.startsWith('acp:');
142
+ const sameCommand = (a, b) => a.file === b.file && a.args.length === b.args.length && a.args.every((arg, i) => arg === b.args[i]);
143
+ // DeepSeek Harness's own ACP command, which continues the sessions Harness keeps in this folder.
144
+ const harnessCommand = (command) => !!command && (knownAcpAgent(command)?.id === 'dsh' || [command.file, ...command.args].some(part => /(^|[\\/])dsh(\.[a-z]+)?$/i.test(part)));
131
145
  const blankSettings = () => ({ format: 1, version: 0, updatedAt: null, backend: null, choices: {}, offered: {} });
132
146
  const acpDriver = (driver) => !!driver && 'configure' in driver;
133
147
  const identity = /^c-[a-f0-9]{16}$/;
@@ -137,6 +151,9 @@ function cut(text, maxBytes) {
137
151
  // Cut on a character boundary; the cut is reported, never silent.
138
152
  return { text: Buffer.from(text).subarray(0, maxBytes).toString('utf8').replace(/�+$/, ''), cut: true };
139
153
  }
154
+ // Some Agents (Kimi Code) title a session with the start of its first prompt, which carries the
155
+ // interface context Rho appends to the person's words; the title keeps the person's words.
156
+ const agentTitleOf = (title) => title ? title.split(/\s*Rho interface context for this message:/)[0].trim() || null : null;
140
157
  function titleOf(text) { const line = text.trim().split('\n')[0].trim(); return line.length > 80 ? line.slice(0, 79) + '…' : line; }
141
158
  // An Agent reports a session it no longer has, in its own words: the ACP load answers "Resource
142
159
  // not found", Claude Code says the session does not exist, and a load that cannot run at all says
@@ -186,6 +203,8 @@ export class Conversations {
186
203
  drivers = new Map();
187
204
  permissions = new Map();
188
205
  checks = new Map();
206
+ // Agents being added, by command: a second request for the same command waits for the first.
207
+ adding = new Map();
189
208
  // Descriptions being written by a model now, by project, conversation, message and file.
190
209
  writing = new Set();
191
210
  describing = { ...describeLimits };
@@ -232,9 +251,10 @@ export class Conversations {
232
251
  const last = c.turns.at(-1), usage = c.observation?.usage;
233
252
  const costs = c.turns.map(t => t.result?.costUsd).filter((v) => typeof v === 'number');
234
253
  return { conversationId: c.id, changeId: c.changeId, requestDigest: c.requestDigest, title: c.title, client: c.client, principal: c.principal, createdAt: c.createdAt, updatedAt: c.updatedAt, version: c.version, turns: c.turns.length, state: last?.state ?? 'idle', lastAt: last?.endedAt ?? last?.sentAt ?? c.createdAt,
235
- listed: 2, turnLimit: !!last && agentTurnLimit(last), waitingPermission: last?.state === 'running' && !!last.permissions?.some(p => p.state === 'pending'), backend: c.backend ?? 'claude-cli', agentTitle: c.observation?.info?.title ?? null, adapter: c.adapter ?? null, model: [...c.turns].reverse().find(t => t.model)?.model ?? null, config: c.config ?? {},
254
+ listed: 2, turnLimit: !!last && agentTurnLimit(last), waitingPermission: last?.state === 'running' && !!last.permissions?.some(p => p.state === 'pending'), backend: c.backend ?? 'claude-cli', agentTitle: agentTitleOf(c.observation?.info?.title), adapter: c.adapter ?? null, model: [...c.turns].reverse().find(t => t.model)?.model ?? null, config: c.config ?? {},
236
255
  usage: usage ? { used: usage.used, size: usage.size, cost: usage.cost ?? null } : null, costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
237
- sessionId: c.acpSessionId ?? c.nativeSessionId ?? null, trashedAt: c.trashedAt ?? null };
256
+ sessionId: c.acpSessionId ?? c.nativeSessionId ?? null, trashedAt: c.trashedAt ?? null,
257
+ origin: c.origin ? { producer: c.origin.producer, where: c.origin.where, ...(c.origin.copy ? { copyOf: c.origin.sessionId } : {}) } : null };
238
258
  }
239
259
  async write(project, c) {
240
260
  const directory = await this.folder(project);
@@ -391,10 +411,12 @@ export class Conversations {
391
411
  // Without an Agent or settings named in the request, the person's saved ones are used and
392
412
  // recorded with the conversation.
393
413
  async start(project, by, input) {
394
- requireThat(!input.backend || backends.includes(input.backend), 'invalid', 'Choose Claude Code or Codex.');
414
+ requireThat(!input.backend || backendName(input.backend), 'invalid', 'Choose an Agent Rho starts.');
395
415
  if (input.config)
396
416
  this.choices(input.config);
397
- const text = this.message(input.text), requestDigest = digest(canonical({ text, ...(input.contexts?.length ? { contexts: input.contexts } : {}), ...(input.backend ? { backend: input.backend } : {}), ...(input.config ? { config: input.config } : {}) }));
417
+ const from = input.continue;
418
+ const text = this.message(input.text), requestDigest = digest(canonical({ text, ...(input.contexts?.length ? { contexts: input.contexts } : {}), ...(input.backend ? { backend: input.backend } : {}), ...(input.config ? { config: input.config } : {}),
419
+ ...(input.uiContext ? { uiContext: input.uiContext } : {}), ...(from ? { continue: { producer: from.producer, sessionId: from.sessionId, copy: from.copy } } : {}) }));
398
420
  return this.serial(project.id, async () => {
399
421
  const index = await this.index(project), existing = index.conversations.find(x => x.changeId === input.changeId);
400
422
  requireThat(!index.deleted?.some(x => x.changeId === input.changeId), 'conflict', 'That request started a conversation that was later deleted. It is not started again.');
@@ -404,16 +426,35 @@ export class Conversations {
404
426
  return this.reply(project, c, c.turns[0]);
405
427
  }
406
428
  requireThat(index.conversations.filter(x => !x.trashedAt).length < this.limits.maxConversations, 'limit', `This project keeps at most ${this.limits.maxConversations} conversations started in Rho. Move one to the trash to start another.`, { limit: 'maxConversations', maximum: this.limits.maxConversations });
429
+ if (from && !from.copy) {
430
+ const already = index.conversations.find(x => x.sessionId === from.sessionId);
431
+ requireThat(!already, 'conflict', already?.trashedAt ? 'This session is a conversation in the trash. Restore it to continue.' : 'This session already continues as a conversation here. Open it to send your message.');
432
+ await this.notHeld(project, from.producer, from.sessionId, false);
433
+ }
407
434
  this.capacity();
408
435
  const saved = await this.readSettings();
409
- const backend = input.backend ?? saved.backend ?? this.config.backend ?? 'claude-acp';
436
+ // A saved Agent the person has since removed is not used; the service's own is.
437
+ const savedBackend = saved.backend && (!saved.backend.startsWith('acp:') || this.commandOf(saved.backend, saved)) ? saved.backend : null;
438
+ const backend = from?.backend ?? input.backend ?? savedBackend ?? this.config.backend ?? 'claude-acp';
439
+ const command = generic(backend) ? this.commandOf(backend, saved) : undefined;
440
+ requireThat(!generic(backend) || command, 'unavailable', backend === 'acp' ? 'No ACP command was named when Rho started. Add the Agent under Agents in Rho. Nothing was sent.' : 'That Agent is not added in Rho. Add it under Agents first. Nothing was sent.');
410
441
  const config = backend === 'claude-cli' ? undefined : input.config ?? saved.choices[backend];
411
- const at = now(), c = { format: 2, backend, id: 'c-' + randomBytes(8).toString('hex'), changeId: input.changeId, requestDigest, createdAt: at, createdBy: by, updatedAt: at, version: 0, client: backend === 'codex-acp' ? 'codex' : backend === 'acp' ? 'acp' : 'claude-code', nativeSessionId: backend === 'claude-cli' ? randomUUID() : null, confirmed: false, principal: null, title: titleOf(text), turns: [], ...(config && Object.keys(config).length ? { config } : {}) };
442
+ const at = now(), c = { format: 2, backend, id: 'c-' + randomBytes(8).toString('hex'), changeId: input.changeId, requestDigest, createdAt: at, createdBy: by, updatedAt: at, version: 0, client: backend === 'codex-acp' ? 'codex' : generic(backend) ? 'acp' : 'claude-code', nativeSessionId: backend === 'claude-cli' ? randomUUID() : null, confirmed: false, principal: null, title: titleOf(text), turns: [], ...(config && Object.keys(config).length ? { config } : {}), ...(command ? { acpCommand: command } : {}) };
412
443
  for (const id of input.contexts ?? [])
413
444
  await this.ports.context?.(project, id);
445
+ if (from) {
446
+ c.origin = { producer: from.producer, sessionId: from.sessionId, where: from.where, copy: from.copy };
447
+ if (from.copy)
448
+ c.forkFrom = from.sessionId;
449
+ else
450
+ c.acpSessionId = from.sessionId;
451
+ c.title = from.title;
452
+ }
414
453
  const turn = this.turn(input.changeId, requestDigest, by, text, 1);
415
454
  if (input.contexts?.length)
416
455
  turn.contexts = [...input.contexts];
456
+ if (input.uiContext)
457
+ turn.uiContext = await this.ports.uiContext?.(project, input.uiContext, this.limits);
417
458
  c.turns.push(turn);
418
459
  // Recorded before the process starts, so a crash in between leaves an unknown message, never a resend.
419
460
  await this.write(project, c);
@@ -421,8 +462,31 @@ export class Conversations {
421
462
  return this.reply(project, c, turn);
422
463
  });
423
464
  }
465
+ /** Whether a process holding a session open is Rho's own connection to this conversation: the
466
+ * Agent its adapter started. */
467
+ async owns(project, conversationId, pid) {
468
+ const adapter = this.drivers.get(project.id + '/' + conversationId)?.driver?.pid;
469
+ if (!adapter || !pid || process.platform === 'win32')
470
+ return false;
471
+ const { execFile } = await import('node:child_process');
472
+ let current = pid;
473
+ for (let depth = 0; depth < 4 && current > 1; depth++) {
474
+ const parent = await new Promise(resolve => execFile('ps', ['-o', 'ppid=', '-p', String(current)], { timeout: 2000 }, (error, out) => resolve(error ? 0 : Number(String(out).trim()))));
475
+ if (parent === adapter)
476
+ return true;
477
+ current = parent;
478
+ }
479
+ return false;
480
+ }
481
+ async notHeld(project, producer, sessionId, existing, conversationId) {
482
+ if (producer !== 'claude-code' && producer !== 'codex')
483
+ return;
484
+ const found = await this.ports.nativeSession?.(project, producer, sessionId) ?? null;
485
+ const holder = found?.open && !(conversationId && await this.owns(project, conversationId, found.open.pid)) ? found.open : null;
486
+ requireThat(!holder, 'condition', `This session is open in ${holder?.where ?? 'another window'}. Close it there to continue here${existing ? '' : ', or continue a copy'}. Nothing was sent.`, { heldBy: holder?.where ?? null });
487
+ }
424
488
  async send(project, by, input) {
425
- const text = this.message(input.text), requestDigest = digest(canonical({ text, ...(input.contexts?.length ? { contexts: input.contexts } : {}), ...(input.continuationOf ? { continuationOf: input.continuationOf } : {}) }));
489
+ const text = this.message(input.text), requestDigest = digest(canonical({ text, ...(input.contexts?.length ? { contexts: input.contexts } : {}), ...(input.uiContext ? { uiContext: input.uiContext } : {}), ...(input.continuationOf ? { continuationOf: input.continuationOf } : {}) }));
426
490
  return this.serial(project.id, async () => {
427
491
  const c = await this.load(project, input.conversationId), existing = c.turns.find(t => t.changeId === input.changeId);
428
492
  if (existing) {
@@ -444,6 +508,9 @@ export class Conversations {
444
508
  requireThat(!alive, 'conflict', `The Agent from before Rho restarted may still be answering (process ${last.pid}). End that process before sending another message.`);
445
509
  }
446
510
  requireThat(c.turns.length < this.limits.maxTurns, 'limit', `A conversation keeps at most ${this.limits.maxTurns} messages. Start a new conversation.`, { limit: 'maxTurns', maximum: this.limits.maxTurns });
511
+ // A session continued in a terminal or another app may be open there now.
512
+ if (c.acpSessionId && (c.client === 'claude-code' || c.client === 'codex'))
513
+ await this.notHeld(project, c.client, c.acpSessionId, true, c.id);
447
514
  requireThat(Buffer.byteLength(JSON.stringify({ ...c, observation: undefined })) + this.limits.maxObservationBytes + this.limits.maxTurnBytes + this.limits.maxMessageBytes <= this.limits.maxRecordBytes, 'limit', 'This conversation has reached the size Rho keeps. Start a new conversation.', { limit: 'maxRecordBytes', maximum: this.limits.maxRecordBytes });
448
515
  this.capacity();
449
516
  for (const old of c.turns)
@@ -456,6 +523,8 @@ export class Conversations {
456
523
  turn.continuationOf = input.continuationOf;
457
524
  if (input.contexts?.length)
458
525
  turn.contexts = [...input.contexts];
526
+ if (input.uiContext)
527
+ turn.uiContext = await this.ports.uiContext?.(project, input.uiContext, this.limits);
459
528
  c.turns.push(turn);
460
529
  await this.write(project, c);
461
530
  this.launch(project, c, turn);
@@ -468,7 +537,7 @@ export class Conversations {
468
537
  this.choices({ [input.configId]: input.value });
469
538
  return this.serial(project.id, async () => {
470
539
  const c = await this.load(project, input.conversationId), last = c.turns.at(-1);
471
- requireThat(c.backend === 'claude-acp' || c.backend === 'codex-acp' || c.backend === 'acp', 'unavailable', 'This conversation\'s Agent has no settings Rho can change.');
540
+ requireThat(c.backend === 'claude-acp' || c.backend === 'codex-acp' || generic(c.backend), 'unavailable', 'This conversation\'s Agent has no settings Rho can change.');
472
541
  requireThat(!c.trashedAt, 'conflict', 'This conversation is in the trash. Restore it to change its settings.');
473
542
  requireThat(!last || last.state !== 'running' || !this.liveTurn(project, c, last), 'conflict', 'The Agent is answering. Change its settings after this message.');
474
543
  const driver = this.drivers.get(project.id + '/' + c.id)?.driver;
@@ -509,20 +578,53 @@ export class Conversations {
509
578
  return value;
510
579
  }
511
580
  settingsView(s) {
512
- const service = this.config.backend ?? 'claude-acp';
513
- return { version: s.version, updatedAt: s.updatedAt, backend: s.backend, serviceBackend: service, choices: s.choices, offered: s.offered, checking: [...this.checks.keys()], describe: { enabled: s.describe?.enabled !== false } };
581
+ const service = this.config.backend ?? 'claude-acp', added = s.added ?? [];
582
+ return { version: s.version, updatedAt: s.updatedAt, backend: s.backend, serviceBackend: service, choices: s.choices, offered: s.offered, checking: [...this.checks.keys()], describe: { enabled: s.describe?.enabled !== false }, agents: this.agents(s),
583
+ added: added.map(a => ({ backend: a.backend, name: a.name, command: [a.command.file, ...a.command.args], addedAt: a.addedAt })),
584
+ // The ACP Agents Rho knows that are installed here and not yet added. Finding them reads PATH.
585
+ installed: installedAcpAgents(process.env).filter(i => !(this.config.acpCommand && sameCommand(i.command, this.config.acpCommand)) && !added.some(a => sameCommand(a.command, i.command)))
586
+ .map(i => ({ id: i.id, name: i.name, command: [i.command.file, ...i.command.args] })) };
587
+ }
588
+ // The command that starts an ACP Agent other than Claude Code or Codex: one the person added, or
589
+ // the one named when this service started.
590
+ commandOf(backend, s) {
591
+ return backend === 'acp' ? this.config.acpCommand : s.added?.find(a => a.backend === backend)?.command;
592
+ }
593
+ // Which Agents this service can start: the person's installed clients, found as the adapters
594
+ // find them, the ACP command named when it started and the ACP Agents the person added. Looking
595
+ // starts nothing. The named command's Agent is named as Rho knows it, else as it named itself the
596
+ // last time it answered, else by its program.
597
+ agents(s) {
598
+ const found = (problem) => problem ? { found: false, problem } : { found: true };
599
+ const command = this.config.acpCommand, reported = s.offered.acp?.adapter?.name;
600
+ return {
601
+ // A command the embedding names is its own choice and is not looked for, as the adapters do.
602
+ 'claude-acp': found(this.config.claudeAcp?.command ? null : missingClient('claude', acpEnvironment('claude', { ...process.env }, this.binary))),
603
+ 'codex-acp': found(this.config.codexAcp?.command ? null : missingClient('codex', acpEnvironment('codex', { ...process.env }, this.config.codex))),
604
+ acp: command ? { found: true, name: knownAcpAgent(command)?.name ?? (harnessCommand(command) ? 'DeepSeek Harness' : reported || path.basename(command.file).replace(/\.(exe|cmd)$/i, '')) } : { found: false, problem: 'No ACP command was named when Rho started.' },
605
+ ...Object.fromEntries((s.added ?? []).map(a => [a.backend, locate(a.command.file, process.env) ? { found: true, name: a.name }
606
+ : { found: false, name: a.name, problem: `${a.name} is no longer at ${a.command.file}. Install it again, or remove it and add it again.` }]))
607
+ };
608
+ }
609
+ // The Agent that continues DeepSeek Harness's sessions: Harness's own ACP command, named when
610
+ // Rho started or added by the person.
611
+ async harness() {
612
+ if (harnessCommand(this.config.acpCommand))
613
+ return 'acp';
614
+ return (await this.readSettings()).added?.find(a => harnessCommand(a.command))?.backend ?? null;
514
615
  }
515
616
  async settings() { return this.settingsView(await this.readSettings()); }
516
617
  // The person's Agent and settings for new conversations. Each Agent's choices are replaced as a
517
618
  // whole and must be among the values it last offered; a stale version is refused.
518
619
  async updateSettings(input) {
519
620
  requireThat(this.config.settingsFile, 'unavailable', 'This service keeps no Agent settings.');
520
- requireThat(input.backend == null || backends.includes(input.backend), 'invalid', 'Choose Claude Code or Codex.');
521
621
  return this.serial('settings', async () => {
522
622
  const s = await this.readSettings();
523
623
  requireThat(s.version === input.expectedVersion, 'conflict', 'The Agent settings changed in another tab. Read them again.', { version: s.version });
624
+ const startable = (backend) => backendName(backend) && (builtIn.includes(backend) || !!this.commandOf(backend, s));
625
+ requireThat(input.backend == null || startable(input.backend), 'invalid', 'Choose an Agent Rho starts.');
524
626
  for (const [backend, values] of Object.entries(input.choices ?? {})) {
525
- requireThat(backends.includes(backend), 'invalid', 'Choose Claude Code or Codex.');
627
+ requireThat(startable(backend), 'invalid', 'Choose an Agent Rho starts.');
526
628
  this.choices(values);
527
629
  const offered = s.offered[backend]?.options;
528
630
  requireThat(offered || !Object.keys(values).length, 'unavailable', 'Check what this Agent offers first.');
@@ -546,7 +648,7 @@ export class Conversations {
546
648
  // ends the process; no message is sent. The protected resource is local Agent processes: one
547
649
  // check per Agent at a time (a second request waits for the same one), each bounded by connectTimeoutMs.
548
650
  async check(project, input) {
549
- requireThat(backends.includes(input.backend), 'invalid', 'Choose Claude Code or Codex.');
651
+ requireThat(backendName(input.backend), 'invalid', 'Choose an Agent Rho starts.');
550
652
  requireThat(this.config.settingsFile, 'unavailable', 'This service keeps no Agent settings.');
551
653
  requireThat(!this.closing, 'unavailable', 'Rho is closing. Nothing was started.');
552
654
  let pending = this.checks.get(input.backend);
@@ -560,7 +662,9 @@ export class Conversations {
560
662
  async inspect(project, backend) {
561
663
  const { ClaudeAcpConversation, CodexAcpConversation, GenericAcpConversation } = await import('@rho-workspace/plugin-agent/conversation');
562
664
  const common = { cwd: project.root, mcpServers: [], limits: this.limits };
563
- const driver = backend === 'codex-acp' ? new CodexAcpConversation({ ...this.config.codexAcp, ...common, codex: this.config.codex }) : backend === 'acp' ? new GenericAcpConversation({ ...common, command: this.config.acpCommand }) : new ClaudeAcpConversation({ preferred: claudePreferred, ...this.config.claudeAcp, ...common, claude: this.binary });
665
+ const command = generic(backend) ? this.commandOf(backend, await this.readSettings()) : undefined;
666
+ requireThat(!generic(backend) || command, 'unavailable', backend === 'acp' ? 'No ACP command was named when Rho started.' : 'That Agent is not added in Rho.');
667
+ const driver = backend === 'codex-acp' ? new CodexAcpConversation({ ...this.config.codexAcp, ...common, codex: this.config.codex }) : command ? new GenericAcpConversation({ ...common, command }) : new ClaudeAcpConversation({ preferred: claudePreferred, ...this.config.claudeAcp, ...common, claude: this.binary });
564
668
  let found;
565
669
  try {
566
670
  found = await driver.inspect();
@@ -581,6 +685,77 @@ export class Conversations {
581
685
  await atomicJson(this.config.settingsFile, s);
582
686
  });
583
687
  }
688
+ // An ACP Agent the person adds for new conversations in every project. Rho starts its command once
689
+ // in this project, as a check does (it opens a session, reads what the Agent offers and removes
690
+ // the session; no message is sent), and keeps it only when the Agent answered. The command is
691
+ // the person's own: a program on PATH or a path to one, and its arguments. Adding the same
692
+ // command again checks it again; one start per command runs at a time.
693
+ async addAgent(project, input) {
694
+ requireThat(this.config.settingsFile, 'unavailable', 'This service keeps no Agent settings.');
695
+ requireThat(!this.closing, 'unavailable', 'Rho is closing. Nothing was started.');
696
+ const [program, ...args] = input.command;
697
+ const file = locate(program, process.env);
698
+ requireThat(file, 'unavailable', `${program} was not found ${/[\\/]/.test(program) ? 'there' : 'on PATH'}. Install it, or give the path of its program.`);
699
+ const command = { file: path.resolve(file), args }, key = JSON.stringify(command);
700
+ let pending = this.adding.get(key);
701
+ if (!pending) {
702
+ pending = this.addOnce(project, command).finally(() => this.adding.delete(key));
703
+ this.adding.set(key, pending);
704
+ }
705
+ return pending;
706
+ }
707
+ async addOnce(project, command) {
708
+ const known = knownAcpAgent(command), label = known?.name ?? path.basename(command.file);
709
+ const room = (s) => requireThat(s.added?.some(a => sameCommand(a.command, command)) || (s.added?.length ?? 0) < this.limits.maxAddedAgents, 'limit', `Rho keeps at most ${this.limits.maxAddedAgents} added Agents. Remove one first. Nothing was started.`, { limit: 'maxAddedAgents', maximum: this.limits.maxAddedAgents });
710
+ room(await this.readSettings());
711
+ const { GenericAcpConversation } = await import('@rho-workspace/plugin-agent/conversation');
712
+ let found;
713
+ try {
714
+ found = await new GenericAcpConversation({ cwd: project.root, mcpServers: [], limits: this.limits, command }).inspect();
715
+ }
716
+ catch (error) {
717
+ throw new Fault('unavailable', `${label} did not start as an ACP Agent (${(error instanceof Error ? error.message : String(error)).replace(/\.$/, '')}). Nothing was added.`);
718
+ }
719
+ return this.serial('settings', async () => {
720
+ const s = await this.readSettings(), added = s.added ?? [], existing = added.find(a => sameCommand(a.command, command));
721
+ room(s);
722
+ const name = existing?.name ?? known?.name ?? found.adapter?.name ?? label;
723
+ const backend = existing?.backend ?? this.newName(known?.id ?? found.adapter?.name ?? label, added);
724
+ if (!existing)
725
+ s.added = [...added, { backend, name, command, addedAt: now() }];
726
+ s.offered[backend] = { at: now(), source: 'check', adapter: found.adapter, options: found.options, omitted: found.omitted };
727
+ s.version++;
728
+ s.updatedAt = now();
729
+ await atomicJson(this.config.settingsFile, s);
730
+ return this.settingsView(s);
731
+ });
732
+ }
733
+ newName(seed, added) {
734
+ const base = seed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'agent';
735
+ for (let n = 1;; n++) {
736
+ const name = `acp:${base}${n > 1 ? '-' + n : ''}`;
737
+ if (!added.some(a => a.backend === name))
738
+ return name;
739
+ }
740
+ }
741
+ // Removing an added Agent takes it out of every Agent menu. Its conversations stay, each with
742
+ // the command it started with, so they can still be read and continued.
743
+ async removeAgent(input) {
744
+ requireThat(this.config.settingsFile, 'unavailable', 'This service keeps no Agent settings.');
745
+ return this.serial('settings', async () => {
746
+ const s = await this.readSettings(), backend = input.backend;
747
+ requireThat(s.added?.some(a => a.backend === backend), 'not_found', 'No Agent added in Rho has that name.');
748
+ s.added = s.added.filter(a => a.backend !== backend);
749
+ delete s.choices[backend];
750
+ delete s.offered[backend];
751
+ if (s.backend === backend)
752
+ s.backend = null;
753
+ s.version++;
754
+ s.updatedAt = now();
755
+ await atomicJson(this.config.settingsFile, s);
756
+ return this.settingsView(s);
757
+ });
758
+ }
584
759
  // A stop is a request until the driver confirms it; what was already done stays done.
585
760
  async stop(project, by, input) {
586
761
  return this.serial(project.id, async () => {
@@ -733,12 +908,20 @@ export class Conversations {
733
908
  }
734
909
  });
735
910
  }
911
+ async changedElsewhere(project, c) {
912
+ if (!c.acpSessionId || (c.client !== 'claude-code' && c.client !== 'codex'))
913
+ return false;
914
+ const previous = c.turns.at(-2), found = await this.ports.nativeSession?.(project, c.client, c.acpSessionId).catch(() => null);
915
+ return !!found && !!previous?.endedAt && Date.parse(found.lastAt) > Date.parse(previous.endedAt) + 5000;
916
+ }
736
917
  async driver(project, c) {
737
918
  return this.serial('driver-pool', async () => {
738
919
  const key = project.id + '/' + c.id, old = this.drivers.get(key);
739
920
  if (old?.timer)
740
921
  clearTimeout(old.timer);
741
- if (old?.driver.connected)
922
+ // An open connection keeps the session as it last saw it. When the session went on in
923
+ // another window since, it is opened again from the Agent's own record.
924
+ if (old?.driver.connected && !await this.changedElsewhere(project, c))
742
925
  return old.driver;
743
926
  if (old)
744
927
  await this.retire(key);
@@ -754,7 +937,7 @@ export class Conversations {
754
937
  const observed = { observation: c.observation, observe: (observation) => {
755
938
  requireThat(observation.sessionId === c.acpSessionId, 'conflict', 'The observed Agent session differs from the retained identity.');
756
939
  requireThat(Buffer.byteLength(JSON.stringify(observation)) <= this.limits.maxObservationBytes, 'limit', 'The session projection exceeded maxObservationBytes; prior observations were kept.');
757
- const config = observation.config, learned = config && config.at !== c.observation?.config?.at && config.origin === 'initial' && !c.config && (c.backend === 'claude-acp' || c.backend === 'codex-acp' || c.backend === 'acp');
940
+ const config = observation.config, learned = config && config.at !== c.observation?.config?.at && config.origin === 'initial' && !c.config && (c.backend === 'claude-acp' || c.backend === 'codex-acp' || generic(c.backend));
758
941
  c.observation = observation;
759
942
  this.schedule(project, c);
760
943
  for (const live of this.live.values())
@@ -770,13 +953,16 @@ export class Conversations {
770
953
  } };
771
954
  // Every session started here is told about the project's shared notes and its section.
772
955
  const instructions = notesInstructions(c.id, { notebook: !!this.ports.notebook?.() });
773
- const common = { cwd: project.root, mcpServers, limits: this.limits, sessionId: c.acpSessionId, config: c.config, instructions, ...observed };
956
+ const common = { cwd: project.root, mcpServers, limits: this.limits, sessionId: c.acpSessionId, ...(!c.acpSessionId && c.forkFrom ? { forkFrom: c.forkFrom } : {}), config: c.config, instructions, ...observed };
957
+ // A conversation started before Rho kept its ACP command uses the one named at this start.
958
+ const command = generic(c.backend) ? c.acpCommand ?? (c.backend === 'acp' ? this.config.acpCommand : undefined) : undefined;
959
+ requireThat(!generic(c.backend) || command, 'unavailable', 'This conversation\'s ACP Agent was named when Rho started, and this start names none. Start Rho with the same --acp-command to continue it.');
774
960
  const driver = c.backend === 'codex-acp'
775
961
  ? new CodexAcpConversation({ ...this.config.codexAcp, ...common, codex: this.config.codex })
776
962
  : c.backend === 'claude-acp'
777
963
  ? new ClaudeAcpConversation({ preferred: claudePreferred, ...this.config.claudeAcp, ...common, claude: this.binary, plugins: [...this.config.claudeAcp?.plugins ?? [], ...await this.ports.claudePlugins?.(project) ?? []] })
778
- : c.backend === 'acp'
779
- ? new GenericAcpConversation({ ...common, command: this.config.acpCommand })
964
+ : command
965
+ ? new GenericAcpConversation({ ...common, command })
780
966
  : new ClaudeCliConversation({ cwd: project.root, binary: this.binary, sessionId: c.nativeSessionId, confirmed: c.confirmed, projectName: project.name ?? path.basename(project.root), mcpServers, limits: this.limits, instructions });
781
967
  this.drivers.set(key, { driver, project, conversation: c });
782
968
  return driver;
@@ -965,7 +1151,7 @@ export class Conversations {
965
1151
  }
966
1152
  }
967
1153
  });
968
- const utterance = turn.text + (turn.continuationOf ? '\n\nOriginal task:\n' + c.turns[0].text : '') + (turn.contexts?.length ? '\n\nRead these exact frozen review contexts with context.read before responding: ' + turn.contexts.join(', ') + '. Reply with annotation.reply and explicit result resource versions.' : '');
1154
+ const utterance = turn.text + uiContextPrompt(turn.uiContext) + (turn.continuationOf ? '\n\nOriginal task:\n' + c.turns[0].text : '') + (turn.contexts?.length ? '\n\nRead these exact frozen review contexts with context.read before responding: ' + turn.contexts.join(', ') + '. Reply with annotation.reply and explicit result resource versions.' : '');
969
1155
  // The Agent's session ended with its process: after Rho restarts, and on a machine that
970
1156
  // never ran the session a project was copied from. The messages Rho kept are the record, so
971
1157
  // the message runs in a new session told what was said before. Nothing is resent as a new
@@ -1071,7 +1257,7 @@ export class Conversations {
1071
1257
  const given = shown.get(file);
1072
1258
  const reason = !enabled ? 'Descriptions by a model are switched off; the facts are kept.'
1073
1259
  : c.backend === 'codex-acp' ? 'Descriptions are written with Claude Code; this session\'s Agent is Codex.'
1074
- : c.backend === 'acp' ? 'Descriptions are written with Claude Code; this session\'s Agent is a configured ACP agent.'
1260
+ : generic(c.backend) ? 'Descriptions are written with Claude Code; this session\'s Agent is another ACP Agent.'
1075
1261
  : !this.ports.describe ? 'No model is set up to describe files.' : null;
1076
1262
  const keep = (why) => { if (record.reason !== why) {
1077
1263
  record.state = record.sentence ? 'described' : 'facts';
package/dist/discovery.js CHANGED
@@ -4,10 +4,10 @@ import { protocol } from './protocol.js';
4
4
  import { schemaClosure } from './protocol-discovery.js';
5
5
  const size = (v) => Buffer.byteLength(JSON.stringify(v)) + 64;
6
6
  export const applicationEffects = Object.fromEntries([
7
- ...['extension.list', 'extension.describe', 'extension.content', 'extension.read', 'protocol.describe', 'protocol.validate', 'project.entries', 'capability.list', 'skill.list', 'skill.read', 'action.lookup', 'action.observe', 'action.output', 'action.list', 'composition.read', 'observation.snapshot', 'interaction.selection.read', 'workspace.observe', 'task.map.read'].map(m => [m, 'read']),
8
- ...['composition.put', 'view.close', 'capability.configure', 'composition.apply', 'composition.undo', 'view.protection.set', 'interaction.selection.set', 'selection.set', 'workspace.enhance', 'task.map.declare', 'task.map.present', 'task.map.respond'].map(m => [m, 'metadata']),
7
+ ...['session.instructions', 'extension.list', 'extension.describe', 'extension.content', 'extension.read', 'protocol.describe', 'protocol.validate', 'project.entries', 'capability.list', 'skill.list', 'skill.read', 'action.lookup', 'action.observe', 'action.output', 'action.list', 'composition.read', 'observation.snapshot', 'interaction.selection.read', 'workspace.observe', 'task.map.read'].map(m => [m, 'read']),
8
+ ...['composition.put', 'view.close', 'capability.configure', 'composition.apply', 'composition.undo', 'composition.delete', 'view.protection.set', 'interaction.selection.set', 'selection.set', 'workspace.enhance', 'task.map.declare', 'task.map.present', 'task.map.respond'].map(m => [m, 'metadata']),
9
9
  ...['workspace.save', 'workspace.export', 'workspace.restore', 'action.export'].map(m => [m, 'file']),
10
- ...['extension.call', 'extension.install', 'extension.manage', 'action.retry', 'action.cancel'].map(m => [m, 'native']),
10
+ ...['extension.call', 'extension.install', 'extension.manage', 'extension.market.change', 'action.retry', 'action.cancel'].map(m => [m, 'native']),
11
11
  ]);
12
12
  const rhoSummary = 'The project in Rho: its folder\'s entries, managed runs, the views on the person\'s screen and what is selected in them, the task map, and which plugins are on.';
13
13
  const metadata = new Set(['capability.index', 'capability.resolve', 'binding.activity.append', 'task.routing.put', 'feedback.submit', 'view.observe', 'view.observations.read']);
@@ -35,6 +35,18 @@ export declare const extensionOperations: {
35
35
  required: string[];
36
36
  additionalProperties: boolean;
37
37
  };
38
+ 'extension.market.change': {
39
+ oneOf: {
40
+ type: string;
41
+ properties: Record<string, unknown>;
42
+ required: string[];
43
+ additionalProperties: boolean;
44
+ }[];
45
+ type: string;
46
+ properties: Record<string, unknown>;
47
+ required: string[];
48
+ additionalProperties: boolean;
49
+ };
38
50
  'extension.manage': {
39
51
  type: string;
40
52
  properties: Record<string, unknown>;
@@ -49,6 +61,12 @@ export declare const extensionResponses: {
49
61
  required: string[];
50
62
  additionalProperties: boolean;
51
63
  };
64
+ 'extension.market.change': {
65
+ type: string;
66
+ properties: Record<string, unknown>;
67
+ required: string[];
68
+ additionalProperties: boolean;
69
+ };
52
70
  'extension.describe': {
53
71
  type: string;
54
72
  properties: Record<string, unknown>;
@@ -88,6 +106,7 @@ export declare const extensionResponses: {
88
106
  };
89
107
  export declare const extensionDescriptions: {
90
108
  'extension.list': string;
109
+ 'extension.market.change': string;
91
110
  'extension.describe': string;
92
111
  'extension.content': string;
93
112
  'extension.call': string;
@@ -1,5 +1,6 @@
1
1
  // Installed third-party packages are application assembly, independent of the
2
2
  // functional owners compiled into Rho. This protocol never imports their code.
3
+ import { marketEntrySchema } from './extension-markets.js';
3
4
  const str = { type: 'string', minLength: 1, maxLength: 1024 };
4
5
  const id = { type: 'string', pattern: '^[a-z0-9][a-z0-9._-]{0,95}$' };
5
6
  const sha = { type: 'string', pattern: '^[a-f0-9]{64}$' };
@@ -7,13 +8,19 @@ const object = (properties, required = Object.keys(properties)) => ({ type: 'obj
7
8
  const json = { type: 'object', additionalProperties: true };
8
9
  const project = { projectId: str };
9
10
  const selected = { ...project, pluginId: id, expectedDigest: sha };
11
+ const marketOrigin = object({ id, title: str, url: str, digest: sha, keywords: { type: 'array', maxItems: 16, items: { type: 'string', minLength: 1, maxLength: 64 } } }, ['id', 'title', 'url', 'digest']);
12
+ const marketSelection = object({ id, expectedDigest: sha, pluginId: id });
13
+ const revision = { type: 'integer', minimum: 1 };
14
+ const marketChange = { ...object({ action: { enum: ['add', 'refresh', 'remove'] }, url: str, id, expectedRevision: revision }, ['action']), oneOf: [object({ action: { const: 'add' }, url: str }), object({ action: { enum: ['refresh', 'remove'] }, id, expectedRevision: revision })] };
15
+ const market = object({ id, url: str, title: str, revision, digest: sha, fetchedAt: str, checkedAt: str, error: { type: ['string', 'null'] }, plugins: { type: 'array', items: marketEntrySchema } });
10
16
  export const extensionOperations = {
11
17
  'extension.list': object(project, []),
12
18
  'extension.describe': object({ ...project, pluginId: id }),
13
19
  'extension.content': object({ ...selected, kind: { enum: ['skill', 'view'] }, entryId: id }),
14
20
  'extension.call': object({ ...selected, operation: id, input: json, changeId: { type: 'string', pattern: '^[A-Za-z0-9._:@-]{1,128}$' } }),
15
21
  'extension.read': object({ ...project, invocationId: sha }),
16
- 'extension.install': object({ source: str, replace: id, expectedDigest: sha }, ['source']),
22
+ 'extension.install': object({ source: str, replace: id, expectedDigest: sha, market: marketSelection }, ['source']),
23
+ 'extension.market.change': marketChange,
17
24
  'extension.manage': object({ pluginId: id, expectedDigest: sha, action: { enum: ['enable', 'disable', 'remove'] } }),
18
25
  };
19
26
  const array = (items) => ({ type: 'array', items });
@@ -23,15 +30,16 @@ const manifest = { ...object({ apiVersion: { const: 1 }, id, title: text, descri
23
30
  operations: array({ ...object({ id, title: text, description: text, effect: { enum: ['read', 'native'] }, methods: array(text), inputSchema: json }), additionalProperties: true }),
24
31
  skills: array(entry), views: array(entry),
25
32
  }, ['apiVersion', 'id', 'title', 'description', 'operations', 'skills', 'views']), additionalProperties: true };
26
- const installed = { id, name: text, version: text, title: text, description: text, digest: sha, source: text, sourceCommit: text, enabled: { type: 'boolean' }, installedAt: text };
27
- const installedRequired = Object.keys(installed).filter(key => key !== 'sourceCommit');
33
+ const installed = { id, name: text, version: text, title: text, description: text, digest: sha, source: text, sourceCommit: text, market: marketOrigin, enabled: { type: 'boolean' }, installedAt: text };
34
+ const installedRequired = Object.keys(installed).filter(key => !['sourceCommit', 'market'].includes(key));
28
35
  const descriptor = object({ ...installed, manifest }, [...installedRequired, 'manifest']);
29
36
  const receipt = object({ invocationId: sha, caller: text, projectId: text, pluginId: id, packageDigest: sha, operation: id, requestDigest: sha,
30
37
  state: { enum: ['pending', 'completed', 'unknown'] }, startedAt: text, endedAt: text, value: {}, error: text,
31
38
  delegations: array(object({ method: text, request: {}, state: { enum: ['pending', 'completed', 'unknown'] }, value: {}, error: text }, ['method', 'request', 'state'])),
32
39
  }, ['invocationId', 'caller', 'projectId', 'pluginId', 'packageDigest', 'operation', 'requestDigest', 'state', 'startedAt']);
33
40
  export const extensionResponses = {
34
- 'extension.list': object({ plugins: array(object(installed, installedRequired)), directory: array(object({ id, title: text, version: text, description: text, source: text, repository: text, needs: text })), limits: json }),
41
+ 'extension.list': object({ plugins: array(object(installed, installedRequired)), directory: array(object({ id, title: text, version: text, description: text, source: text, repository: text, needs: text })), markets: array(market), limits: json }),
42
+ 'extension.market.change': object({ markets: array(market) }),
35
43
  'extension.describe': descriptor,
36
44
  'extension.install': descriptor,
37
45
  'extension.content': object({ pluginId: id, packageDigest: sha, entryId: id, kind: { enum: ['skill', 'view'] }, title: text, text }),
@@ -40,7 +48,8 @@ export const extensionResponses = {
40
48
  'extension.manage': object({ pluginId: id, action: { enum: ['enable', 'disable', 'remove'] }, retained: text }),
41
49
  };
42
50
  export const extensionDescriptions = {
43
- 'extension.list': 'List independently installed third-party packages and their enabled state, source and immutable digest. Reads start no plugin or runtime. Use extension.describe to discover operation schemas and Skills.',
51
+ 'extension.list': 'List installed third-party packages, the bundled directory and retained remote market indexes. Listing makes no network request and starts no plugin or runtime. Use extension.describe for installed operation schemas and Skills.',
52
+ 'extension.market.change': 'Local person/operator: add an HTTPS plugin market index, refresh it explicitly, or remove its source. Failed refresh retains the last valid index; removal preserves installed plugins. Listing never refreshes a source.',
44
53
  'extension.describe': 'Read an installed third-party package manifest, operation input schemas, required Rho methods, Skills and views. Read its Skill with extension.content before use. Disabled packages cannot run.',
45
54
  'extension.content': 'Read one installed plugin Skill or standalone HTML view at its selected package digest. Reading starts no plugin or runtime.',
46
55
  'extension.call': 'Call an enabled third-party operation at the described package digest, under the caller and project. The package runs trusted native code. Non-read calls retain the original changeId: repeat observes that invocation, including unknown outcomes, without re-execution. Completion is not scientific validity.',
@@ -48,5 +57,5 @@ export const extensionDescriptions = {
48
57
  'extension.install': 'Local person/operator: install a trusted prebuilt npm package, local folder, tarball, or Git source. Lifecycle scripts are disabled. Replacement stages and checks the new package before switching; use replace and expectedDigest for updates.',
49
58
  'extension.manage': 'Local person/operator: enable, disable or remove an installed third-party package at the expected digest. Existing native tasks, R sessions, project files and retained receipts are preserved.',
50
59
  };
51
- export const extensionManagement = ['extension.install', 'extension.manage'];
60
+ export const extensionManagement = ['extension.install', 'extension.manage', 'extension.market.change'];
52
61
  export const extensionMethods = Object.keys(extensionOperations);
@@ -0,0 +1,74 @@
1
+ export declare const marketLimits: {
2
+ maxMarkets: number;
3
+ maxMarketBytes: number;
4
+ maxMarketEntries: number;
5
+ marketTimeoutMs: number;
6
+ };
7
+ export declare const marketEntrySchema: {
8
+ type: string;
9
+ properties: Record<string, unknown>;
10
+ required: string[];
11
+ additionalProperties: boolean;
12
+ };
13
+ export declare const marketIndexSchema: {
14
+ type: string;
15
+ properties: Record<string, unknown>;
16
+ required: string[];
17
+ additionalProperties: boolean;
18
+ };
19
+ export interface MarketEntry {
20
+ id: string;
21
+ title: string;
22
+ version: string;
23
+ description: string;
24
+ source: string;
25
+ repository: string;
26
+ needs: string;
27
+ keywords?: string[];
28
+ }
29
+ export interface ExtensionMarket {
30
+ id: string;
31
+ url: string;
32
+ title: string;
33
+ revision: number;
34
+ digest: string;
35
+ fetchedAt: string;
36
+ checkedAt: string;
37
+ error: string | null;
38
+ plugins: MarketEntry[];
39
+ }
40
+ export interface MarketSelection {
41
+ id: string;
42
+ expectedDigest: string;
43
+ pluginId: string;
44
+ }
45
+ export type MarketChange = {
46
+ action: 'add';
47
+ url: string;
48
+ } | {
49
+ action: 'refresh' | 'remove';
50
+ id: string;
51
+ expectedRevision: number;
52
+ };
53
+ export declare class ExtensionMarkets {
54
+ readonly directory: string;
55
+ readonly limits: typeof marketLimits;
56
+ private values;
57
+ constructor(directory: string, limits: typeof marketLimits);
58
+ init(): Promise<void>;
59
+ list(): ExtensionMarket[];
60
+ private save;
61
+ private fetch;
62
+ private observe;
63
+ change(input: MarketChange): Promise<ExtensionMarket[]>;
64
+ select(selection: MarketSelection, source: string): {
65
+ entry: MarketEntry;
66
+ origin: {
67
+ id: string;
68
+ title: string;
69
+ url: string;
70
+ digest: string;
71
+ keywords: string[];
72
+ };
73
+ };
74
+ }