@rho-workspace/rho 0.5.5 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/agent-connect.d.ts +8 -0
  2. package/dist/agent-connect.js +15 -0
  3. package/dist/browser-access.js +1 -1
  4. package/dist/cli.js +38 -8
  5. package/dist/contract.generated.d.ts +148 -5
  6. package/dist/contract.generated.js +1 -1
  7. package/dist/contracts/owners.generated.js +107 -12
  8. package/dist/conversations.d.ts +157 -4
  9. package/dist/conversations.js +69 -6
  10. package/dist/discovery.js +1 -1
  11. package/dist/protocol.d.ts +1 -0
  12. package/dist/protocol.js +12 -5
  13. package/dist/server.d.ts +2 -10
  14. package/dist/server.js +71 -8
  15. package/dist/web/.vite/manifest.json +61 -61
  16. package/dist/web/assets/{choices-b0XhrGQV.js → choices-D-YfnFP6.js} +1 -1
  17. package/dist/web/assets/{console-BSjLGe0T.js → console-Trqc0swD.js} +1 -1
  18. package/dist/web/assets/{dist-Buk0lwzI.js → dist-DZmszrjG.js} +1 -1
  19. package/dist/web/assets/{dist-Cw8hRIlT.js → dist-DpIHRk3B.js} +1 -1
  20. package/dist/web/assets/{dist-foH9IKc6.js → dist-eMx6b8N1.js} +1 -1
  21. package/dist/web/assets/{dist-Tzoe_zm-.js → dist-pF902jHA.js} +1 -1
  22. package/dist/web/assets/{explore-in-BVUy3ZW_.js → explore-in-BCHqjJ7B.js} +1 -1
  23. package/dist/web/assets/{graph-1OdIJ9l7.js → graph-lENgfQNH.js} +1 -1
  24. package/dist/web/assets/index-B3_OVy91.css +1 -0
  25. package/dist/web/assets/{index-CxmItXqa.js → index-BpL_Kl_s.js} +84 -83
  26. package/dist/web/assets/{notes-meaning-pVbbQUGP.js → notes-meaning-DcVGUBBY.js} +1 -1
  27. package/dist/web/assets/{overview-B9UtVJ32.js → overview-CDnVKlOt.js} +3 -3
  28. package/dist/web/assets/{preview-CksD_5KS.js → preview-BEC1E9MK.js} +1 -1
  29. package/dist/web/assets/{review-CMxQlQ-C.js → review-DYP0aCXb.js} +1 -1
  30. package/dist/web/assets/{skills-BFNSyrnD.js → skills-1ec0Z-jx.js} +1 -1
  31. package/dist/web/assets/{skills-work-BGPSnF_I.js → skills-work-DNexV4OJ.js} +1 -1
  32. package/dist/web/assets/{studio-process-CsRoTHOh.js → studio-process-EYQgikxq.js} +1 -1
  33. package/dist/web/assets/{timeline-events-BbuuFWHV.js → timeline-events-Chj2Gbx7.js} +1 -1
  34. package/dist/web/assets/{work-turns-BvJlIMmA.js → work-turns-CT9RVcWY.js} +1 -1
  35. package/dist/web/assets/{workspace-CWlkjLNx.js → workspace-COmCbGV3.js} +1 -1
  36. package/dist/web/composition.json +58 -58
  37. package/dist/web/index.html +2 -2
  38. package/dist/web/renderer.json +1 -1
  39. package/docs/FRONTEND.md +26 -18
  40. package/docs/HEADLESS.md +37 -27
  41. package/node_modules/@rho-workspace/plugin-agent/README.md +29 -0
  42. package/node_modules/@rho-workspace/plugin-agent/dist/acp-command.d.ts +1 -0
  43. package/node_modules/@rho-workspace/plugin-agent/dist/acp-command.js +9 -1
  44. package/node_modules/@rho-workspace/plugin-agent/dist/acp-conversation.d.ts +1 -0
  45. package/node_modules/@rho-workspace/plugin-agent/dist/acp-conversation.js +10 -0
  46. package/node_modules/@rho-workspace/plugin-agent/dist/external-session-contract.js +10 -5
  47. package/node_modules/@rho-workspace/plugin-agent/dist/external-session.d.ts +12 -1
  48. package/node_modules/@rho-workspace/plugin-agent/dist/native-sessions.d.ts +70 -0
  49. package/node_modules/@rho-workspace/plugin-agent/dist/native-sessions.js +538 -0
  50. package/node_modules/@rho-workspace/plugin-agent/package.json +2 -1
  51. package/node_modules/hono/dist/adapter/aws-lambda/handler.js +1 -1
  52. package/node_modules/hono/dist/cjs/adapter/aws-lambda/handler.js +1 -1
  53. package/node_modules/hono/dist/cjs/adapter/bun/serve-static.js +1 -1
  54. package/node_modules/hono/dist/cjs/adapter/bun/websocket.js +1 -1
  55. package/node_modules/hono/dist/cjs/client/fetch-result-please.js +1 -1
  56. package/node_modules/hono/dist/cjs/helper/accepts/accepts.js +2 -1
  57. package/node_modules/hono/dist/cjs/helper/ssg/ssg.js +5 -3
  58. package/node_modules/hono/dist/cjs/jsx/base.js +49 -30
  59. package/node_modules/hono/dist/cjs/jsx/components.js +2 -2
  60. package/node_modules/hono/dist/cjs/jsx/constants.js +2 -0
  61. package/node_modules/hono/dist/cjs/jsx/jsx-runtime.js +13 -17
  62. package/node_modules/hono/dist/cjs/middleware/etag/index.js +3 -1
  63. package/node_modules/hono/dist/cjs/middleware/pretty-json/index.js +1 -0
  64. package/node_modules/hono/dist/cjs/middleware/timing/timing.js +4 -3
  65. package/node_modules/hono/dist/cjs/utils/cookie.js +2 -2
  66. package/node_modules/hono/dist/cjs/utils/mime.js +1 -1
  67. package/node_modules/hono/dist/cjs/utils/url.js +1 -1
  68. package/node_modules/hono/dist/client/fetch-result-please.js +1 -1
  69. package/node_modules/hono/dist/helper/accepts/accepts.js +2 -1
  70. package/node_modules/hono/dist/helper/ssg/ssg.js +5 -3
  71. package/node_modules/hono/dist/jsx/base.js +49 -32
  72. package/node_modules/hono/dist/jsx/components.js +2 -2
  73. package/node_modules/hono/dist/jsx/constants.js +2 -1
  74. package/node_modules/hono/dist/jsx/jsx-runtime.js +15 -19
  75. package/node_modules/hono/dist/middleware/etag/index.js +3 -1
  76. package/node_modules/hono/dist/middleware/language/language.js +1 -1
  77. package/node_modules/hono/dist/middleware/pretty-json/index.js +1 -0
  78. package/node_modules/hono/dist/middleware/timing/timing.js +4 -3
  79. package/node_modules/hono/dist/utils/cookie.js +2 -2
  80. package/node_modules/hono/dist/utils/mime.js +1 -1
  81. package/node_modules/hono/dist/utils/url.js +1 -1
  82. package/node_modules/hono/package.json +1 -1
  83. package/package.json +2 -2
  84. package/protocol/index.json +39 -33
  85. package/protocol/operations/agent.sessions.list.json +49 -3
  86. package/protocol/operations/agent.sessions.read.json +53 -4
  87. package/protocol/operations/conversation.configure.json +29 -0
  88. package/protocol/operations/conversation.decide.json +29 -0
  89. package/protocol/operations/conversation.list.json +29 -0
  90. package/protocol/operations/conversation.permission.json +29 -0
  91. package/protocol/operations/conversation.read.json +29 -0
  92. package/protocol/operations/conversation.restore.json +29 -0
  93. package/protocol/operations/conversation.send.json +29 -0
  94. package/protocol/operations/conversation.settings.check.json +92 -0
  95. package/protocol/operations/conversation.settings.read.json +92 -0
  96. package/protocol/operations/conversation.settings.update.json +92 -0
  97. package/protocol/operations/conversation.start.json +46 -1
  98. package/protocol/operations/conversation.stop.json +29 -0
  99. package/protocol/operations/conversation.trash.json +29 -0
  100. package/protocol/operations/observation.snapshot.json +29 -0
  101. package/protocol/operations/session.instructions.json +43 -0
  102. package/vendor/composition.json +74 -74
  103. package/vendor/rho-workspace-plugin-agent-0.4.30.tgz +0 -0
  104. package/web/theme.css +12 -0
  105. package/dist/web/assets/index-CsC3sTRq.css +0 -1
  106. package/vendor/rho-workspace-plugin-agent-0.4.29.tgz +0 -0
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { atomicJson, canonical, digest, Fault, now, readJson, requireThat } from '@rho-workspace/plugin-contracts';
11
+ import { acpEnvironment, missingClient } from '@rho-workspace/plugin-agent/acp-command';
11
12
  // Rho holds user messages and their original identities. The selected Agent plugin
12
13
  // owns the connection, native session and protocol. Reads never start a driver.
13
14
  // A new Claude Code session opens in Manual mode unless the person's Claude settings name another.
@@ -234,7 +235,8 @@ export class Conversations {
234
235
  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
236
  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 ?? {},
236
237
  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 };
238
+ sessionId: c.acpSessionId ?? c.nativeSessionId ?? null, trashedAt: c.trashedAt ?? null,
239
+ origin: c.origin ? { producer: c.origin.producer, where: c.origin.where, ...(c.origin.copy ? { copyOf: c.origin.sessionId } : {}) } : null };
238
240
  }
239
241
  async write(project, c) {
240
242
  const directory = await this.folder(project);
@@ -394,7 +396,9 @@ export class Conversations {
394
396
  requireThat(!input.backend || backends.includes(input.backend), 'invalid', 'Choose Claude Code or Codex.');
395
397
  if (input.config)
396
398
  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 } : {}) }));
399
+ const from = input.continue;
400
+ 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 } : {}),
401
+ ...(from ? { continue: { producer: from.producer, sessionId: from.sessionId, copy: from.copy } } : {}) }));
398
402
  return this.serial(project.id, async () => {
399
403
  const index = await this.index(project), existing = index.conversations.find(x => x.changeId === input.changeId);
400
404
  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,13 +408,26 @@ export class Conversations {
404
408
  return this.reply(project, c, c.turns[0]);
405
409
  }
406
410
  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 });
411
+ if (from && !from.copy) {
412
+ const already = index.conversations.find(x => x.sessionId === from.sessionId);
413
+ 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.');
414
+ await this.notHeld(project, from.producer, from.sessionId, false);
415
+ }
407
416
  this.capacity();
408
417
  const saved = await this.readSettings();
409
- const backend = input.backend ?? saved.backend ?? this.config.backend ?? 'claude-acp';
418
+ const backend = from?.backend ?? input.backend ?? saved.backend ?? this.config.backend ?? 'claude-acp';
410
419
  const config = backend === 'claude-cli' ? undefined : input.config ?? saved.choices[backend];
411
420
  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 } : {}) };
412
421
  for (const id of input.contexts ?? [])
413
422
  await this.ports.context?.(project, id);
423
+ if (from) {
424
+ c.origin = { producer: from.producer, sessionId: from.sessionId, where: from.where, copy: from.copy };
425
+ if (from.copy)
426
+ c.forkFrom = from.sessionId;
427
+ else
428
+ c.acpSessionId = from.sessionId;
429
+ c.title = from.title;
430
+ }
414
431
  const turn = this.turn(input.changeId, requestDigest, by, text, 1);
415
432
  if (input.contexts?.length)
416
433
  turn.contexts = [...input.contexts];
@@ -421,6 +438,29 @@ export class Conversations {
421
438
  return this.reply(project, c, turn);
422
439
  });
423
440
  }
441
+ /** Whether a process holding a session open is Rho's own connection to this conversation: the
442
+ * Agent its adapter started. */
443
+ async owns(project, conversationId, pid) {
444
+ const adapter = this.drivers.get(project.id + '/' + conversationId)?.driver?.pid;
445
+ if (!adapter || !pid || process.platform === 'win32')
446
+ return false;
447
+ const { execFile } = await import('node:child_process');
448
+ let current = pid;
449
+ for (let depth = 0; depth < 4 && current > 1; depth++) {
450
+ const parent = await new Promise(resolve => execFile('ps', ['-o', 'ppid=', '-p', String(current)], { timeout: 2000 }, (error, out) => resolve(error ? 0 : Number(String(out).trim()))));
451
+ if (parent === adapter)
452
+ return true;
453
+ current = parent;
454
+ }
455
+ return false;
456
+ }
457
+ async notHeld(project, producer, sessionId, existing, conversationId) {
458
+ if (producer !== 'claude-code' && producer !== 'codex')
459
+ return;
460
+ const found = await this.ports.nativeSession?.(project, producer, sessionId) ?? null;
461
+ const holder = found?.open && !(conversationId && await this.owns(project, conversationId, found.open.pid)) ? found.open : null;
462
+ 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 });
463
+ }
424
464
  async send(project, by, input) {
425
465
  const text = this.message(input.text), requestDigest = digest(canonical({ text, ...(input.contexts?.length ? { contexts: input.contexts } : {}), ...(input.continuationOf ? { continuationOf: input.continuationOf } : {}) }));
426
466
  return this.serial(project.id, async () => {
@@ -444,6 +484,9 @@ export class Conversations {
444
484
  requireThat(!alive, 'conflict', `The Agent from before Rho restarted may still be answering (process ${last.pid}). End that process before sending another message.`);
445
485
  }
446
486
  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 });
487
+ // A session continued in a terminal or another app may be open there now.
488
+ if (c.acpSessionId && (c.client === 'claude-code' || c.client === 'codex'))
489
+ await this.notHeld(project, c.client, c.acpSessionId, true, c.id);
447
490
  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
491
  this.capacity();
449
492
  for (const old of c.turns)
@@ -510,7 +553,19 @@ export class Conversations {
510
553
  }
511
554
  settingsView(s) {
512
555
  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 } };
556
+ 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() };
557
+ }
558
+ // Which Agents this service can start: the person's installed clients, found as the adapters
559
+ // find them, and an ACP command the person named. Looking starts nothing.
560
+ agents() {
561
+ const found = (problem) => problem ? { found: false, problem } : { found: true };
562
+ const command = this.config.acpCommand;
563
+ return {
564
+ // A command the embedding names is its own choice and is not looked for, as the adapters do.
565
+ 'claude-acp': found(this.config.claudeAcp?.command ? null : missingClient('claude', acpEnvironment('claude', { ...process.env }, this.binary))),
566
+ 'codex-acp': found(this.config.codexAcp?.command ? null : missingClient('codex', acpEnvironment('codex', { ...process.env }, this.config.codex))),
567
+ acp: command ? { found: true, name: /(^|[\\/])dsh(\.[a-z]+)?$/i.test(command.file) || command.args.some(a => /(^|[\\/])dsh(\.[a-z]+)?$/i.test(a)) ? 'DeepSeek Harness' : null } : { found: false, problem: 'Start Rho with --agent-backend acp --acp-command to use another ACP Agent.' }
568
+ };
514
569
  }
515
570
  async settings() { return this.settingsView(await this.readSettings()); }
516
571
  // The person's Agent and settings for new conversations. Each Agent's choices are replaced as a
@@ -733,12 +788,20 @@ export class Conversations {
733
788
  }
734
789
  });
735
790
  }
791
+ async changedElsewhere(project, c) {
792
+ if (!c.acpSessionId || (c.client !== 'claude-code' && c.client !== 'codex'))
793
+ return false;
794
+ const previous = c.turns.at(-2), found = await this.ports.nativeSession?.(project, c.client, c.acpSessionId).catch(() => null);
795
+ return !!found && !!previous?.endedAt && Date.parse(found.lastAt) > Date.parse(previous.endedAt) + 5000;
796
+ }
736
797
  async driver(project, c) {
737
798
  return this.serial('driver-pool', async () => {
738
799
  const key = project.id + '/' + c.id, old = this.drivers.get(key);
739
800
  if (old?.timer)
740
801
  clearTimeout(old.timer);
741
- if (old?.driver.connected)
802
+ // An open connection keeps the session as it last saw it. When the session went on in
803
+ // another window since, it is opened again from the Agent's own record.
804
+ if (old?.driver.connected && !await this.changedElsewhere(project, c))
742
805
  return old.driver;
743
806
  if (old)
744
807
  await this.retire(key);
@@ -770,7 +833,7 @@ export class Conversations {
770
833
  } };
771
834
  // Every session started here is told about the project's shared notes and its section.
772
835
  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 };
836
+ const common = { cwd: project.root, mcpServers, limits: this.limits, sessionId: c.acpSessionId, ...(!c.acpSessionId && c.forkFrom ? { forkFrom: c.forkFrom } : {}), config: c.config, instructions, ...observed };
774
837
  const driver = c.backend === 'codex-acp'
775
838
  ? new CodexAcpConversation({ ...this.config.codexAcp, ...common, codex: this.config.codex })
776
839
  : c.backend === 'claude-acp'
package/dist/discovery.js CHANGED
@@ -4,7 +4,7 @@ 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']),
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
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']),
9
9
  ...['workspace.save', 'workspace.export', 'workspace.restore', 'action.export'].map(m => [m, 'file']),
10
10
  ...['extension.call', 'extension.install', 'extension.manage', 'action.retry', 'action.cancel'].map(m => [m, 'native']),
@@ -173,6 +173,7 @@ export declare const descriptions: {
173
173
  'conversation.settings.update': string;
174
174
  'conversation.settings.check': string;
175
175
  'session.connect': string;
176
+ 'session.instructions': string;
176
177
  'conversation.read': string;
177
178
  'project.work.read': string;
178
179
  'resource.register': string;
package/dist/protocol.js CHANGED
@@ -71,7 +71,8 @@ export const operations = { ...base.operations, ...extensionOperations,
71
71
  'data.compare': obj({ ...project, left: ref, right: ref, options: obj({ leftKey: text, rightKey: text, leftNamespace: text, rightNamespace: text, mapping: { const: 'equal-string' }, leftField: text, rightField: text, join: { enum: ['left', 'inner', 'full'] } }) }),
72
72
  'media.describe': obj({ ...project, resource: ref }),
73
73
  // A conversation the person starts in Rho: their words, under a request identity they hold.
74
- 'conversation.start': obj({ ...project, changeId, text: { type: 'string', minLength: 1, maxLength: 16384 }, backend: agentBackend, config: choices, contexts: { ...array(digest), maxItems: 8 } }, ['projectId', 'changeId', 'text']),
74
+ 'conversation.start': obj({ ...project, changeId, text: { type: 'string', minLength: 1, maxLength: 16384 }, backend: agentBackend, config: choices, contexts: { ...array(digest), maxItems: 8 },
75
+ continue: obj({ session: { type: 'string', pattern: '^x-[a-f0-9]{64}$' }, copy: { type: 'boolean' } }, ['session']) }, ['projectId', 'changeId', 'text']),
75
76
  'conversation.send': obj({ ...project, conversationId: changeId, changeId, text: { type: 'string', minLength: 1, maxLength: 16384 }, contexts: { ...array(digest), maxItems: 8 }, continuationOf: changeId }, ['projectId', 'conversationId', 'changeId', 'text']),
76
77
  'conversation.permission': obj({ ...project, conversationId: changeId, turnId: changeId, permissionId: changeId, optionId: { type: 'string', minLength: 1, maxLength: 256 } }),
77
78
  'conversation.stop': obj({ ...project, conversationId: changeId, turnId: changeId }),
@@ -87,7 +88,8 @@ export const operations = { ...base.operations, ...extensionOperations,
87
88
  'conversation.settings.update': obj({ ...project, expectedVersion: integer, backend: { anyOf: [agentBackend, { type: 'null' }] }, choices: obj({ 'claude-acp': choices, 'codex-acp': choices, 'acp': choices }, []), describe: obj({ enabled: { type: 'boolean' } }, ['enabled']) }, ['projectId', 'expectedVersion']),
88
89
  'conversation.settings.check': obj({ ...project, backend: agentBackend }),
89
90
  // Claude Code or Codex running outside Rho, given this project and told how to start Rho's MCP server.
90
- 'session.connect': obj({ ...project, agent: outsideAgent })
91
+ 'session.connect': obj({ ...project, agent: outsideAgent }),
92
+ 'session.instructions': obj(project)
91
93
  };
92
94
  // Every owner-declared operation and response comes from the snapshot, scoped once.
93
95
  for (const [method, contract] of Object.entries(ownerOperations))
@@ -200,14 +202,17 @@ const catalog = { ...contract.capabilityCatalogSchema };
200
202
  const conversationSummary = response({ conversationId: text, title: text, client: text, principal: { anyOf: [text, { type: 'null' }] }, createdAt: text, updatedAt: text, version: integer, turns: integer, state: { enum: ['idle', 'running', 'completed', 'failed', 'stopped', 'unknown'] }, lastAt: text,
201
203
  backend: text, agentTitle: { anyOf: [text, { type: 'null' }] }, adapter: { anyOf: [response({ name: text, version: text }, ['name']), { type: 'null' }] }, model: { anyOf: [text, { type: 'null' }] }, config: choices, turnLimit: { type: 'boolean' }, waitingPermission: { type: 'boolean' },
202
204
  usage: { anyOf: [response({ used: { type: 'number', minimum: 0 }, size: { type: 'number', minimum: 0 }, cost: { anyOf: [response({ amount: { type: 'number' }, currency: text }), { type: 'null' }] } }), { type: 'null' }] },
203
- costUsd: { anyOf: [{ type: 'number' }, { type: 'null' }] }, sessionId: { anyOf: [text, { type: 'null' }] }, trashedAt: { anyOf: [text, { type: 'null' }] } }, ['conversationId', 'title', 'client', 'principal', 'createdAt', 'updatedAt', 'version', 'turns', 'state', 'lastAt']);
205
+ costUsd: { anyOf: [{ type: 'number' }, { type: 'null' }] }, sessionId: { anyOf: [text, { type: 'null' }] }, trashedAt: { anyOf: [text, { type: 'null' }] },
206
+ origin: { anyOf: [response({ producer: text, where: text, copyOf: text }, ['producer', 'where']), { type: 'null' }] } }, ['conversationId', 'title', 'client', 'principal', 'createdAt', 'updatedAt', 'version', 'turns', 'state', 'lastAt']);
204
207
  const sessionObservation = ownerSchema('agent-session', 'sessionObservationSchema');
205
208
  // What one Agent offered, from a check or a conversation's first session, and the person's choices.
206
209
  const offered = response({ at: text, source: { enum: ['check', 'conversation'] }, adapter: { anyOf: [response({ name: text, version: text }, ['name']), { type: 'null' }] }, options: array(ownerSchema('agent-session', 'configOptionSchema')), omitted: integer });
207
210
  const agentSettings = response({ version: integer, updatedAt: { anyOf: [text, { type: 'null' }] }, backend: { anyOf: [agentBackend, { type: 'null' }] }, serviceBackend: { enum: ['claude-acp', 'codex-acp', 'claude-cli', 'acp'] },
208
211
  choices: response({ 'claude-acp': choices, 'codex-acp': choices, 'acp': choices }, []), offered: response({ 'claude-acp': offered, 'codex-acp': offered, 'acp': offered }, []), checking: array(agentBackend),
209
212
  // Whether a model describes the files a message writes; the facts are read either way.
210
- describe: response({ enabled: { type: 'boolean' } }) }, ['version', 'updatedAt', 'backend', 'serviceBackend', 'choices', 'offered', 'checking']);
213
+ describe: response({ enabled: { type: 'boolean' } }),
214
+ // Which Agents this service can start, without starting them.
215
+ agents: response(Object.fromEntries(['claude-acp', 'codex-acp', 'acp'].map(k => [k, response({ found: { type: 'boolean' }, problem: text, name: { anyOf: [text, { type: 'null' }] } }, ['found'])]))) }, ['version', 'updatedAt', 'backend', 'serviceBackend', 'choices', 'offered', 'checking']);
211
216
  // A tool step keeps its original identity, the Agent's bounded human title, its ACP
212
217
  // category and one identifying input (command, path, pattern or query), for a command the
213
218
  // Agent's own few words on what it is for (purpose), when it was
@@ -333,6 +338,7 @@ export const responses = { ...base.responses, ...ownerResponses, ...extensionRes
333
338
  'conversation.settings.update': agentSettings,
334
339
  'conversation.settings.check': agentSettings,
335
340
  'session.connect': response({ principal: text, label: text, methods: integer, granted: { type: 'boolean' }, added: addedTo }),
341
+ 'session.instructions': response({ request: text, prompt: text, command: array(text) }),
336
342
  'protocol.describe': response({ apiVersion: { const: '2.0' }, schemaVersion: { const: 2 }, revision: digest, entries: array(response({ method: text, summary: text })), next: { type: ['string', 'null'] }, complete: { type: 'boolean' }, contract: response({ method: text, inputSchema: { type: 'object' }, outputSchema: { type: 'object' }, definitions: { type: 'object' }, description: text }) }, ['apiVersion', 'schemaVersion', 'revision', 'entries', 'next', 'complete']),
337
343
  'capability.list': { ...catalog, properties: { ...catalog.properties, host: contract.cliSchema }, required: [...(catalog.required ?? []), 'host'] },
338
344
  'capability.configure': response({ version: positive, enabled: array(text), receipt: response({ changeId: text, author: text, version: positive, enabled: array(text), at: text }) }),
@@ -395,7 +401,7 @@ export const descriptions = {
395
401
  'action.submit': 'Execute the explicitly selected project R script snapshot and requirements. Persist operationId before delivery; identical retries retain the original Core identity. Output directory is the first R argument, followed by explicit args or frozen resource paths. Artifact paths are output-relative; completion is run-directory-relative, returned as plan.completion, so an inventory can remain beside immutable producer artifacts.',
396
402
  'binding.activity.append': 'Registered native adapter only: retain original CLI tool and artifact-ready observations. This does not execute, validate, or present artifacts.',
397
403
  'task.routing.put': 'Control plane only: conditionally assign one task and business binding to this project without starting a native session.',
398
- 'conversation.start': 'Person at this machine only: start a conversation with the selected Claude Code or Codex Agent in this project. Rho selects the Agent plugin backend, attaches this project\'s MCP grant and records the message before sending it. Without backend or config, the person\'s saved Agent and settings are used and recorded with the conversation. A repeated changeId with the same text returns the original conversation and starts nothing.',
404
+ 'conversation.start': 'Person at this machine only: start a conversation with the selected Claude Code or Codex Agent in this project. Rho selects the Agent plugin backend, attaches this project\'s MCP grant and records the message before sending it. Without backend or config, the person\'s saved Agent and settings are used and recorded with the conversation. A repeated changeId with the same text returns the original conversation and starts nothing. With continue, the message continues an Agent session listed by agent.sessions.list (one started in a terminal, an editor or the Agent\'s own app) through that same session, or through a copy of it with copy; a session another live window holds open is refused unless copied.',
399
405
  'conversation.send': 'Person at this machine only: send the next message to a conversation started in Rho. A continuationOf links a new message to the latest confirmed Agent turn-limit stop; the original request is not resent. Refused while the previous message is being answered. A repeated changeId with the same input returns the original message.',
400
406
  'conversation.decide': 'Person at this machine only: say to use an answer, or that it needs changes and why. The decision holds for the file versions that message kept when it was given and says nothing of later versions. A new decision supersedes the one before; none is removed. Refused while the Agent is still answering.',
401
407
  'conversation.stop': 'Person at this machine only: ask the original Agent to stop answering one message. ACP cancellation is confirmed by the original prompt response; a lost connection keeps the outcome unknown. Legacy CLI conversations confirm process exit. Steps already taken stay done.',
@@ -432,6 +438,7 @@ export const descriptions = {
432
438
  'conversation.settings.update': 'Person at this machine only: choose the Agent and its settings for new conversations, among the values that Agent last offered, and whether a model describes the files a message writes. A stale expectedVersion is refused.',
433
439
  'conversation.settings.check': 'Person at this machine only: start the chosen Agent in this project to read the settings it offers, then remove that session and end the process. No message is sent; one check per Agent runs at a time.',
434
440
  'session.connect': 'Person at this machine only: give Claude Code or Codex running outside Rho access to this project, then add Rho\'s MCP server with that Agent\'s own `mcp add` (Claude Code: for this project folder; Codex: in its user configuration). A grant whose connection still reaches this service is kept, so a connected session keeps working; a new grant the Agent refuses is removed. No message is sent and no Agent session starts.',
441
+ 'session.instructions': 'Person at this machine or operator only: read a prompt for the person\'s own MCP client to connect itself to this existing project. Returns local launcher paths, never credential contents. Reading or copying grants no access, edits no client configuration and starts no runtime.',
435
442
  'conversation.read': 'Read a conversation started in Rho: the person\'s messages, the Agent\'s visible text and tool steps, and how each message ended. Reading starts and resumes nothing.',
436
443
  'project.work.read': 'Person at this machine only: read linked Agent sessions, retained native activity identities, current referenced files and the shared notes document. Source cursors and resource versions remain independent. Does not start an Agent, collector or Host.',
437
444
  'resource.register': 'Declare scoped existing bytes and their type/schema at the supplied digest. This read and registration starts no runtime.',
package/dist/server.d.ts CHANGED
@@ -168,22 +168,14 @@ export declare class RhoService {
168
168
  agentConnection(projectId: string, externalSessionId: string, file: string, methods?: string[], pluginFilter?: string[], profile?: 'layered-business' | 'layered-presentation', who?: {
169
169
  label?: string;
170
170
  conversationId?: string;
171
- }): Promise<{
172
- apiVersion: "2.0";
173
- pluginFilter?: ("media" | "r" | "agent" | "files" | "skills" | "documents" | "requirements" | "tables" | "charts" | "annotations" | "console" | "kernel")[] | undefined;
174
- profile?: "layered-business" | "layered-presentation" | undefined;
175
- url: string;
176
- token: string;
177
- projectId: string;
178
- externalSessionId: string;
179
- methods: string[];
180
- }>;
171
+ }): Promise<any>;
181
172
  private authenticate;
182
173
  private handle;
183
174
  private readBody;
184
175
  private json;
185
176
  private serial;
186
177
  private recordActivity;
178
+ private agentSessions;
187
179
  private projectActivity;
188
180
  private agentSummaries;
189
181
  private dataDir;
package/dist/server.js CHANGED
@@ -19,7 +19,7 @@ import { Interactions } from './interactions.js';
19
19
  import { TaskMap } from './task-map.js';
20
20
  import { Capabilities } from './capabilities.js';
21
21
  import { BrowserAccess, localBrowserId, localBrowserPrincipal } from './browser-access.js';
22
- import { addToAgent, connectLimits, mcpCommand, mcpName, outsideLabel } from './agent-connect.js';
22
+ import { addToAgent, connectionInstructions, connectLimits, mcpCommand, mcpName, outsideLabel } from './agent-connect.js';
23
23
  import { NativeFolderPicker, nativeFolderLimits } from './native-folders.js';
24
24
  import { homedir } from 'node:os';
25
25
  import { serveWeb, servePageFrame, pageFramePath, extensionFramePath, webRoot, installedWebEntries } from './web-assets.js';
@@ -452,6 +452,7 @@ export class RhoService {
452
452
  return [await writeClaudePlugin(path.join(this.state.directory, 'claude-plugins'), { name: 'rho', version: selectedPluginVersions.agent, description: 'Skills and hooks of the plugins selected in Rho.', ...declared })];
453
453
  },
454
454
  changed: async (project, conversationId) => { void this.serial(project.id, () => this.emit(project, 'conversation.changed', conversationId, localBrowserId)).catch(() => { }); },
455
+ nativeSession: async (project, producer, sessionId) => (await import('@rho-workspace/plugin-agent/native-sessions')).nativeSession(project.root, producer, sessionId),
455
456
  // The person's own Claude Code when one is named, with no tools and in an empty folder of
456
457
  // Rho's state, so it reads only what it is given and loads no project instructions. What
457
458
  // a call cost comes from Claude Code when it knows the model's price, else from models.dev.
@@ -570,11 +571,22 @@ export class RhoService {
570
571
  requireThat(selected.every(m => !extensionManagement.includes(m) && !!operations[m] && !m.startsWith('agent.sessions.') && !m.startsWith('session.') && !m.startsWith('conversation.') && (!m.startsWith('project.') || m === 'project.entries') && !['task.routing.put', 'console.run', 'console.stop', 'kernel.stop'].includes(m)), 'invalid', 'Unknown or operator-only Agent capability.');
571
572
  requireThat(await Promise.all(selected.map(method => capabilities.isAvailable(method, state))).then(values => values.every(Boolean)), 'unavailable', 'A requested Agent operation has no selected provider.');
572
573
  requireThat(!profile || new Set(['capability.index', 'capability.resolve', ...selected]).size <= layeredGrantLimits.maxMethods, 'limit', `Layered connection methods exceed the ${layeredGrantLimits.maxMethods}-method profile; choose an explicit narrower grant.`, { limit: 'maxMethods', maximum: layeredGrantLimits.maxMethods });
573
- const token = randomBytes(32).toString('hex');
574
574
  const id = 'agent:' + digest(projectId + ':' + externalSessionId).slice(0, 32);
575
+ const grantedMethods = [...new Set([...(profile ? ['capability.index', 'capability.resolve'] : ['protocol.describe', 'capability.list']), ...selected])];
576
+ const existing = this.state.principals.find(p => p.id === id);
577
+ const kept = await fs.readFile(file, 'utf8').then(text => JSON.parse(text), () => null);
578
+ // Repeating the same setup must not disconnect a client already using its grant.
579
+ // Changed scope, expired access or a stale address still requires a fresh credential.
580
+ if (existing && !(existing.expiresAt && Date.parse(existing.expiresAt) <= Date.now())
581
+ && kept?.url === this.url && kept.projectId === projectId && kept.externalSessionId === externalSessionId
582
+ && typeof kept.token === 'string' && digest(kept.token) === existing.tokenHash
583
+ && canonical([...existing.methods].sort()) === canonical([...grantedMethods].sort())
584
+ && kept.profile === profile && canonical(existing.pluginFilter ?? []) === canonical(pluginFilter ?? []))
585
+ return kept;
586
+ const token = randomBytes(32).toString('hex');
575
587
  this.state.principals = this.state.principals.filter(p => p.id !== id);
576
588
  requireThat(!pluginFilter || pluginFilter.every(id => this.plugins.isKnown(id)), 'invalid', 'Unknown MCP plugin filter.');
577
- await this.state.addPrincipal({ id, tokenHash: digest(token), projectId, label: who.label ?? externalSessionId, ...(who.conversationId ? { conversationId: who.conversationId } : {}), methods: [...new Set([...(profile ? ['capability.index', 'capability.resolve'] : ['protocol.describe', 'capability.list']), ...selected])], ...(profile ? { profile } : {}), ...(pluginFilter ? { pluginFilter } : {}) });
589
+ await this.state.addPrincipal({ id, tokenHash: digest(token), projectId, label: who.label ?? externalSessionId, ...(who.conversationId ? { conversationId: who.conversationId } : {}), methods: grantedMethods, ...(profile ? { profile } : {}), ...(pluginFilter ? { pluginFilter } : {}) });
578
590
  const connection = { url: this.url, token, projectId, externalSessionId, methods: [...new Set([...(profile ? ['capability.index', 'capability.resolve'] : []), ...selected])], ...(profile ? { profile } : {}), ...(pluginFilter ? { pluginFilter } : {}), apiVersion: '2.0' };
579
591
  await atomicJson(file, connection);
580
592
  return connection;
@@ -785,6 +797,32 @@ export class RhoService {
785
797
  // What the project's Agents did, for the person's screen:
786
798
  // only principals whose grant names this project, only the fields the activity record
787
799
  // keeps. A grant is the scope; an Agent need not have opened a binding.
800
+ // Every Agent session in the project folder, wherever it was started: Harness conversations Rho
801
+ // observes and the sessions Claude Code and Codex keep in their own files. Each names the Agent
802
+ // Rho continues it with and the Rho conversation that already continues it. Reads files only.
803
+ async agentSessions(p) {
804
+ const agent = this.plugins.optional('agent');
805
+ const external = agent ? agent.listExternalSessions(p.root) : { sessions: [], omitted: 0 };
806
+ const native = (await import('@rho-workspace/plugin-agent/native-sessions')).listNativeSessions(p.root);
807
+ const conversations = [...await this.conversations.list(p, {}), ...await this.conversations.list(p, { trashed: true })];
808
+ const bySession = new Map(conversations.filter(c => c.sessionId).map(c => [c.sessionId, c]));
809
+ const sessions = [...external.sessions, ...native.sessions].flatMap(s => {
810
+ const conversation = bySession.get(s.sessionId);
811
+ if (conversation?.trashedAt)
812
+ return [];
813
+ const continueWith = s.producer === 'claude-code' ? 'claude-acp' : s.producer === 'codex' ? 'codex-acp' : s.producer === 'dsh' && this.config.acpCommand ? 'acp' : null;
814
+ // Harness reports a turn in progress, not whether a window holds the session idle; a turn
815
+ // Rho itself is running is Rho's.
816
+ const open = s.producer === 'dsh' && s.state === 'running' && conversation?.state !== 'running' ? { where: 'DeepSeek Harness' } : s.open;
817
+ return [{ ...s, continueWith, ...(open !== undefined ? { open } : {}), ...(conversation ? { conversationId: conversation.conversationId } : {}) }];
818
+ });
819
+ // A session held only by Rho's own connection is not open elsewhere.
820
+ for (const s of sessions)
821
+ if (s.open && s.conversationId && await this.conversations.owns(p, s.conversationId, s.open.pid))
822
+ s.open = null;
823
+ sessions.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
824
+ return { sessions: sessions.slice(0, 128), omitted: external.omitted + native.omitted + Math.max(0, sessions.length - 128) };
825
+ }
788
826
  async projectActivity(p) {
789
827
  const agent = this.plugins.project(p.root, p.id).agent;
790
828
  if (!agent)
@@ -1156,10 +1194,15 @@ export class RhoService {
1156
1194
  await this.emit(p, 'composition.changed', b.compositionId);
1157
1195
  return { document, viewId, composition };
1158
1196
  }
1197
+ if (method === 'session.instructions') {
1198
+ requireThat(principal.id === localBrowserId || principal.id === 'operator', 'unauthorized', 'Only the person at this machine can read Agent setup instructions.');
1199
+ return connectionInstructions(p, this.state.directory);
1200
+ }
1159
1201
  if (method === 'session.grant') {
1160
1202
  requireThat(principal.id === 'operator', 'unauthorized', 'Only the local operator may provision a project-scoped session.');
1161
1203
  const file = path.join(this.state.directory, 'agent-' + digest(p.id + ':' + b.externalSessionId) + '.json');
1162
1204
  const connection = await this.agentConnection(p.id, b.externalSessionId, file, b.methods, undefined, b.profile);
1205
+ await this.emit(p, 'activity.changed', method);
1163
1206
  return { profile: connection.profile, connectionFile: file, projectId: p.id, methods: connection.methods, transport: 'stdio-mcp', externalSessionId: b.externalSessionId };
1164
1207
  }
1165
1208
  // The person's own Claude Code or Codex, outside Rho: the same grant the operator gives, kept
@@ -1190,11 +1233,16 @@ export class RhoService {
1190
1233
  // The person at this machine talks to a conversation Rho started; Agents and narrower links
1191
1234
  // cannot start one, and an Agent started this way cannot start another.
1192
1235
  if (method.startsWith('agent.sessions.')) {
1193
- requireThat(principal.id === localBrowserId, 'unauthorized', 'External conversation history belongs to the person at this machine.');
1194
- const agent = this.plugins.need('agent');
1236
+ requireThat(principal.id === localBrowserId, 'unauthorized', 'Agent session history belongs to the person at this machine.');
1195
1237
  if (method === 'agent.sessions.list')
1196
- return agent.listExternalSessions(p.root);
1197
- return agent.readExternalSession(p.root, b);
1238
+ return this.agentSessions(p);
1239
+ const listed = (await this.agentSessions(p)).sessions.find(s => s.id === b.session);
1240
+ requireThat(listed, 'not_found', 'That Agent session is not in this project.');
1241
+ const read = listed.producer === 'claude-code' || listed.producer === 'codex'
1242
+ ? await (await import('@rho-workspace/plugin-agent/native-sessions')).readNativeSession(p.root, b)
1243
+ : this.plugins.need('agent').readExternalSession(p.root, b);
1244
+ requireThat(read, 'not_found', 'That Agent session is no longer in this project.');
1245
+ return { ...read, session: { ...read.session, ...listed } };
1198
1246
  }
1199
1247
  if (method === 'project.work.read') {
1200
1248
  requireThat(principal.id === localBrowserId, 'scope', 'Project work is available to the person at this machine.');
@@ -1235,10 +1283,25 @@ export class RhoService {
1235
1283
  if (method === 'conversation.settings.read')
1236
1284
  return this.conversations.settings();
1237
1285
  this.plugins.need('agent');
1286
+ if (method === 'conversation.start' && b.continue) {
1287
+ const listed = (await this.agentSessions(p)).sessions.find(s => s.id === b.continue.session);
1288
+ requireThat(listed, 'not_found', 'That Agent session is not in this project. Nothing was sent.');
1289
+ requireThat(listed.continueWith, 'condition', listed.producer === 'dsh'
1290
+ ? 'Rho continues DeepSeek Harness sessions through Harness\'s ACP command. Start Rho with --agent-backend acp --acp-command \'["dsh","acp"]\', or continue in Harness. Nothing was sent.'
1291
+ : 'Rho cannot continue this Agent\'s sessions. Nothing was sent.');
1292
+ requireThat(!listed.conversationId, 'conflict', 'This session already continues as a conversation here. Open it to send your message.');
1293
+ requireThat(!b.continue.copy || listed.producer !== 'dsh', 'condition', 'DeepSeek Harness cannot copy a session. Continue it in place once it is not answering. Nothing was sent.');
1294
+ requireThat(!listed.open || listed.producer !== 'dsh', 'condition', 'DeepSeek Harness is answering in this session now. Continue it once that turn ends. Nothing was sent.', { heldBy: 'DeepSeek Harness' });
1295
+ return this.conversations.start(p, principal.id, { ...b, continue: { producer: listed.producer, sessionId: listed.sessionId, title: listed.title, where: listed.where ?? (listed.producer === 'dsh' ? 'DeepSeek Harness' : 'another window'), copy: !!b.continue.copy, backend: listed.continueWith } });
1296
+ }
1238
1297
  if (method === 'conversation.start')
1239
1298
  return this.conversations.start(p, principal.id, b);
1240
- if (method === 'conversation.send')
1299
+ if (method === 'conversation.send') {
1300
+ // A Harness session reports its own turns through its adapter; Rho does not write into one it is answering.
1301
+ const listed = (await this.agentSessions(p)).sessions.find(s => s.conversationId === b.conversationId && s.producer === 'dsh');
1302
+ requireThat(!listed?.open, 'condition', 'DeepSeek Harness is answering in this session now. Send once that turn ends. Nothing was sent.', { heldBy: 'DeepSeek Harness' });
1241
1303
  return this.conversations.send(p, principal.id, b);
1304
+ }
1242
1305
  if (method === 'conversation.configure')
1243
1306
  return this.conversations.configure(p, principal.id, b);
1244
1307
  if (method === 'conversation.settings.update')