@volter/supercode-orchestrator 0.5.9 → 0.5.10

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.
package/README.md CHANGED
@@ -318,6 +318,16 @@ daemon start attaches the `rh2` channel for that profile. Uninstalling in RH2 re
318
318
  the channel stops. A `platforms: rh2` block grants no integration authority and the runtime reports
319
319
  it as unsupported. Native Hermes channel configuration remains the worker's configuration.
320
320
 
321
+ A mail agent the RH2 organization declares is one participant there, as its own agent principal: an admin issues that
322
+ agent a token at RH2's agent-token door, and the home keeps it for the agent's channel, which then joins its Room's
323
+ conversation answering every line:
324
+
325
+ ```sh
326
+ supercode orchestrator apps install rh2-agent --origin https://pilot.runhuman.com --organization volter \
327
+ --principal prn_… --token-file ./account-manager.token --profile account-manager
328
+ supercode orchestrator apps remove rh2-agent.account-manager
329
+ ```
330
+
321
331
  **The Chat SDK sidecar** mounts `chat@4.39.0` (npm package `chat`, repo `vercel/chat`; a
322
332
  devDependency of this package, installed alongside `@chat-adapter/state-memory@4.39.0`). The
323
333
  platform packages (`@chat-adapter/slack`, …) are **not** dependencies: the one the block names is
package/activation.mjs CHANGED
@@ -206,7 +206,10 @@ export const APPROVAL_POLICY = Object.freeze({ codex: 'untrusted' });
206
206
  * policy on `thread/start` and its sandbox as a launch override.
207
207
  */
208
208
  export const OPEN_POLICY = Object.freeze({ codex: 'never' });
209
- export const OPEN_ARGUMENTS = Object.freeze({ codex: ['-c', 'sandbox_mode="danger-full-access"'] });
209
+ // Claude Code takes its open policy at launch (its permission mode), as supercode's relay runs it: with approvals off a
210
+ // dispatched Claude worker otherwise asks for every tool and, with nobody attending, is denied (measured: the review
211
+ // World's Claude coder could not run one command).
212
+ export const OPEN_ARGUMENTS = Object.freeze({ codex: ['-c', 'sandbox_mode="danger-full-access"'], 'claude-code': ['--permission-mode', 'bypassPermissions'] });
210
213
 
211
214
  const approvalsOff = (profile) => String(profile?.residue?.config?.approvals?.mode ?? 'manual').trim().toLowerCase() === 'off';
212
215
 
@@ -0,0 +1,124 @@
1
+ // A channel carried to an agent's one mailbox (docs/adr/0008-agent-mailbox.md, decisions 15 and 16).
2
+ //
3
+ // Any channel adapter (connect(emit) / send(target, content)) is the transport. Each line a person posts is filed as
4
+ // channel mail to the agent (`harness.v1.sessions.message` to `{harness: 'agent', session_id: <name>}`), with the
5
+ // platform's id as its marker and, for a reply, its parent's id as its reply marker: the mailbox makes a line with no
6
+ // known parent a root, which goes to the agent's main session, and puts a reply in its parent's thread, which reaches
7
+ // every participant. This bridge's own mailbox (`fromName`) is a participant of every thread it opens, so the agent's
8
+ // answers are filed there; each is posted back anchored at its thread's root, so the agent never posts directly in
9
+ // the channel. Where a thread's root was posted, and which root each posted answer belongs to, is kept in `state` (a
10
+ // JSON file when `statePath` is given), so answers find their anchor after a restart, and a person's reply to one of the
11
+ // agent's answers (whose platform id the mailbox never saw) is filed in that answer's thread.
12
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+
15
+ const DEFAULT_POLL_MS = 2000;
16
+ const ANSWER_MEMORY = 2000;
17
+
18
+ export class AgentMailboxBridge {
19
+ constructor({ adapter, client, agent, platform = adapter?.platform ?? 'channel', fromName = `${platform}-channel`, statePath = null, log = () => {}, pollMs = DEFAULT_POLL_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
20
+ if (!adapter || !client || !agent) throw new Error('AgentMailboxBridge needs an adapter, a harness client and an agent name');
21
+ this.adapter = adapter;
22
+ this.client = client;
23
+ this.agent = agent;
24
+ this.platform = platform;
25
+ this.fromName = fromName;
26
+ this.statePath = statePath;
27
+ this.log = log;
28
+ this.pollMs = pollMs;
29
+ this.sleep = sleep;
30
+ this.running = false;
31
+ this.loop = null;
32
+ /** mailbox thread id -> { chat_id, root }: where the thread's root was posted. */
33
+ this.threads = new Map();
34
+ /** platform id of an answer this bridge posted -> its thread's root (a platform id the mailbox knows as a marker). */
35
+ this.answers = new Map();
36
+ if (statePath) {
37
+ try {
38
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
39
+ for (const [thread, where] of Object.entries(state.threads ?? {})) this.threads.set(thread, where);
40
+ for (const [answer, root] of Object.entries(state.answers ?? {})) this.answers.set(answer, root);
41
+ } catch { /* a first start has no state */ }
42
+ }
43
+ }
44
+
45
+ save() {
46
+ if (!this.statePath) return;
47
+ mkdirSync(dirname(this.statePath), { recursive: true });
48
+ const temporary = `${this.statePath}.${process.pid}.tmp`;
49
+ writeFileSync(temporary, JSON.stringify({ threads: Object.fromEntries(this.threads), answers: Object.fromEntries(this.answers) }));
50
+ renameSync(temporary, this.statePath);
51
+ }
52
+
53
+ async start() {
54
+ await this.adapter.connect((event) => { this.inbound(event).catch((error) => this.log('warn', `${this.platform}: not filed: ${error.message}`)); });
55
+ this.running = true;
56
+ this.loop = this.pollAnswers();
57
+ }
58
+
59
+ async stop() {
60
+ this.running = false;
61
+ await this.adapter.close?.();
62
+ if (this.loop) { try { await this.loop; } catch { /* stopping */ } this.loop = null; }
63
+ }
64
+
65
+ /** One line from the channel, filed as mail to the agent. */
66
+ async inbound(event) {
67
+ if (event.is_me || !event.message_id) return;
68
+ const marker = `${this.platform}:${event.message_id}`;
69
+ const result = await this.client.messageSession({ harness: 'agent', session_id: this.agent }, event.text, {
70
+ fromName: this.fromName,
71
+ senderName: event.sender_name ?? event.sender,
72
+ channel: true,
73
+ marker,
74
+ // The parent a platform names (RH2's `parentMessageId`), else the thread the line is in (Slack's `thread_ts` is its
75
+ // root's own id). A person's line is one the mailbox filed, so its marker is known; an answer this bridge posted is
76
+ // not, so a reply to one is filed against that answer's root.
77
+ replyMarker: this.replyMarkerOf(event),
78
+ surface: `${this.platform}:${event.key.chat_id}`,
79
+ idempotencyKey: marker,
80
+ });
81
+ if (result.delivered_to_bus === false && result.refusal) {
82
+ this.log('warn', `${this.platform}: ${this.agent} did not take ${marker}: ${result.refusal.message}`);
83
+ return;
84
+ }
85
+ const thread = result.thread ?? result.message_id;
86
+ if (thread && !this.threads.has(thread)) {
87
+ this.threads.set(thread, { chat_id: event.key.chat_id, root: event.key.thread_id ?? event.message_id });
88
+ this.save();
89
+ }
90
+ }
91
+
92
+ /** The mailbox marker a line replies to, or none for a root. */
93
+ replyMarkerOf(event) {
94
+ const parent = event.parent_id ?? event.key.thread_id;
95
+ if (!parent) return undefined;
96
+ return `${this.platform}:${this.answers.get(String(parent)) ?? parent}`;
97
+ }
98
+
99
+ /** The agent's answers, filed in this bridge's mailbox, posted in their threads. */
100
+ async pollAnswers() {
101
+ while (this.running) {
102
+ try {
103
+ const inbox = await this.client.sessionInbox({ fromName: this.fromName });
104
+ for (const { envelope } of inbox.messages ?? []) {
105
+ if (envelope.kind === 'notice') continue;
106
+ const where = this.threads.get(envelope.thread ?? envelope.in_reply_to ?? '');
107
+ if (!where) {
108
+ this.log('warn', `${this.platform}: ${envelope.id} names no thread this channel opened; not posted`);
109
+ continue;
110
+ }
111
+ const posted = await this.adapter.send({ platform: this.platform, kind: 'channel', chat_id: where.chat_id, thread_id: where.root }, { text: envelope.body });
112
+ if (posted?.message_id) {
113
+ this.answers.set(String(posted.message_id), String(where.root));
114
+ if (this.answers.size > ANSWER_MEMORY) this.answers.delete(this.answers.keys().next().value);
115
+ this.save();
116
+ }
117
+ }
118
+ } catch (error) {
119
+ this.log('warn', `${this.platform}: answers not read: ${error.message}`);
120
+ }
121
+ await this.sleep(this.pollMs);
122
+ }
123
+ }
124
+ }
package/adapters/rh2.mjs CHANGED
@@ -19,11 +19,12 @@
19
19
  // The Room-chat app (docs/adr/0017-room-chat-is-an-installed-app.md) attaches it with its RH2 installation's
20
20
  // origin, organization, principal and token; a `platforms:` block never does.
21
21
  //
22
- // The surface key is `{platform: 'rh2', kind: 'channel', chat_id: '<roomId>'}`: a Room is one surface, so the agent it
23
- // seats is one session however many of the Room's conversations it takes part in. A line wakes it where RH2 says the bot
24
- // answers every line (its participation, `answers: all`) or where the line mentions it; the line arrives labelled with its
25
- // conversation, and a reply goes back to the conversation of the Room's last line (`<roomId>/<conversationId>` names one)
26
- // unless the agent begins it with a conversation's label, `[Work]`, which sends it there.
22
+ // The surface is the thread (docs/adr/0008-agent-mailbox.md, decision 16): `{platform: 'rh2', kind: 'channel',
23
+ // chat_id: '<roomId>/<conversationId>'}` for a line posted directly in a conversation, which is a root, and the same key
24
+ // with `thread_id: '<root message id>'` for a reply, which belongs to its anchor's thread (RH2's `parentMessageId`; a
25
+ // reply to a reply joins the thread its parent is in). Each line also carries `parent_id`, the platform id it answers. A
26
+ // line wakes the bot where RH2 says it answers every line (its participation, `answers: all`) or where the line mentions
27
+ // it. A reply to a thread posts anchored at the thread's root, so the bot never posts directly in the conversation.
27
28
  import { chunks } from './chunks.mjs';
28
29
 
29
30
  const DEFAULT_POLL_MS = 2000;
@@ -32,6 +33,7 @@ const MAX_BODY = 4000;
32
33
  const MESSAGE_PAGE = 100;
33
34
  const CLOCK_SKEW_MS = 60_000;
34
35
  const ECHO_MEMORY = 500;
36
+ const THREAD_MEMORY = 5000;
35
37
 
36
38
  export class Rh2Adapter {
37
39
  constructor({ platform = 'rh2', profile = 'default', channel = null, vault = new Map(), refreshVault = () => false, log = () => {}, fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
@@ -49,8 +51,8 @@ export class Rh2Adapter {
49
51
  this.cursors = new Map();
50
52
  /** room id -> { name, names: Map<principalId, displayName>, conversations: Map<id, name> } */
51
53
  this.rooms = new Map();
52
- /** roomId -> the conversation of the last line that woke the Room's session: where a reply goes. */
53
- this.replyTo = new Map();
54
+ /** message id -> the root of the thread it is in (its own id for a root). */
55
+ this.threadOf = new Map();
54
56
  this.roomsRefreshedAt = 0;
55
57
  this.ownMessageIds = [];
56
58
  this.postedIn = new Map(); // message id -> the conversation it was posted in, for its edits
@@ -232,18 +234,27 @@ export class Rh2Adapter {
232
234
  const mentions = Array.isArray(message.mentions) ? message.mentions.map(String) : [];
233
235
  const mentioned = me !== '' && mentions.includes(me);
234
236
  // RH2 decides who hears a line: the bot answers where it takes every line, and elsewhere only when named.
237
+ // A line posted directly in the conversation is a root; a reply belongs to its anchor's thread.
238
+ const parentId = message.parentMessageId === undefined || message.parentMessageId === null ? null : String(message.parentMessageId);
239
+ const root = parentId === null ? messageId : (this.threadOf.get(parentId) ?? parentId);
240
+ if (messageId !== null && root !== null) {
241
+ this.threadOf.set(messageId, root);
242
+ if (this.threadOf.size > THREAD_MEMORY) this.threadOf.delete(this.threadOf.keys().next().value);
243
+ }
235
244
  if (!isMe && conversation?.answers !== 'all' && !mentioned) return;
236
- if (!isMe) this.replyTo.set(roomId, conversationId);
245
+ const chatId = `${roomId}/${conversationId}`;
237
246
  // A person's line opens a turn: show typing here until the reply lands (the loop has no
238
247
  // typing effect by design — the adapter, which knows both ends of the turn, carries it).
239
- if (!isMe) this.startTyping(`${roomId}/${conversationId}`);
240
- const labelled = room && room.conversations.size > 1 && conversation ? `[${conversation.name}] ${text}` : text;
248
+ if (!isMe) this.startTyping(chatId);
249
+ const key = { platform: this.platform, kind: 'channel', chat_id: chatId };
250
+ if (parentId !== null && root !== null) key.thread_id = root;
241
251
  this.emit({
242
252
  profile: this.profile,
243
- key: { platform: this.platform, kind: 'channel', chat_id: roomId },
253
+ key,
254
+ parent_id: parentId,
244
255
  sender: author || 'unknown',
245
256
  sender_name: room?.names.get(author) ?? null,
246
- text: labelled,
257
+ text,
247
258
  at: typeof message.createdAt === 'string' ? message.createdAt : new Date().toISOString(),
248
259
  message_id: messageId,
249
260
  is_me: isMe,
@@ -256,11 +267,7 @@ export class Rh2Adapter {
256
267
 
257
268
  split(chatId) {
258
269
  const [roomId, conversationId] = String(chatId).split('/');
259
- if (conversationId) return { roomId, conversationId };
260
- const last = this.replyTo.get(roomId);
261
- if (last) return { roomId, conversationId: last };
262
- for (const [id, cursor] of this.cursors) if (cursor.roomId === roomId) return { roomId, conversationId: id };
263
- return { roomId, conversationId: undefined };
270
+ return { roomId, conversationId };
264
271
  }
265
272
 
266
273
  startTyping(chatId) {
@@ -277,35 +284,23 @@ export class Rh2Adapter {
277
284
  if (timer) { clearInterval(timer); this.typingTimers.delete(chatId); }
278
285
  }
279
286
 
280
- /**
281
- * Where a message goes: the conversation the agent names by beginning it with `[<name>]`, as each line reaches it
282
- * labelled, or else the one it last answered. A turn a tester's line opened is still the agent's to narrate in Work.
283
- */
284
- addressed(roomId, conversationId, text) {
285
- const named = /^\[([^\]\n]{1,64})\]\s*/.exec(text);
286
- const room = this.rooms.get(roomId);
287
- if (!named || !room) return { conversationId, text };
288
- for (const [id, conversation] of room.conversations) {
289
- if (conversation.name.toLowerCase() === named[1].trim().toLowerCase()) return { conversationId: id, text: text.slice(named[0].length) };
290
- }
291
- return { conversationId, text };
292
- }
293
-
294
287
  async send(target, content) {
295
288
  this.stopTyping(String(target.chat_id));
296
- const split = this.split(target.chat_id);
297
- if (!split.conversationId) throw new Error(`${this.platform}: no conversation for ${target.chat_id}`);
298
- const { roomId } = split;
299
- const { conversationId, text } = this.addressed(roomId, split.conversationId, String(content.text ?? ''));
289
+ const { roomId, conversationId } = this.split(target.chat_id);
290
+ if (!conversationId) throw new Error(`${this.platform}: no conversation for ${target.chat_id}`);
291
+ const text = String(content.text ?? '');
292
+ // Every line of a reply is anchored at its thread's root, so it stays in the thread.
293
+ const anchor = content.reply_to ?? target.thread_id ?? null;
300
294
  // A reply longer than one line holds goes out as consecutive lines, split where a paragraph (or a line) ends.
301
295
  let messageId;
302
- for (const [index, part] of chunks(text, MAX_BODY).entries()) {
296
+ for (const part of chunks(text, MAX_BODY)) {
303
297
  const body = { body: part, correlationId: `orchestrator:${crypto.randomUUID()}`, kind: 'agent' };
304
- if (content.reply_to && index === 0) body.parentMessageId = String(content.reply_to);
298
+ if (anchor) body.parentMessageId = String(anchor);
305
299
  const res = await this.post(`/rooms/${roomId}/conversations/${conversationId}/messages`, body);
306
300
  if (!res.ok) throw new Error(`${this.platform}: post -> HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
307
301
  const posted = (await res.json()).data ?? {};
308
302
  messageId = String(posted.messageId ?? crypto.randomUUID());
303
+ if (anchor) this.threadOf.set(messageId, this.threadOf.get(String(anchor)) ?? String(anchor));
309
304
  this.ownMessageIds.push(messageId);
310
305
  this.postedIn.set(messageId, conversationId);
311
306
  if (this.postedIn.size > ECHO_MEMORY) this.postedIn.delete(this.postedIn.keys().next().value);
@@ -321,10 +316,10 @@ export class Rh2Adapter {
321
316
  async edit(target, messageId, content) {
322
317
  const split = this.split(target.chat_id);
323
318
  // An edit goes to the conversation the message was posted in, whichever the agent named then.
324
- const posted = this.postedIn.get(String(messageId)) ?? split.conversationId;
325
- if (!posted) throw new Error(`${this.platform}: no conversation for ${target.chat_id}`);
319
+ const conversationId = this.postedIn.get(String(messageId)) ?? split.conversationId;
320
+ if (!conversationId) throw new Error(`${this.platform}: no conversation for ${target.chat_id}`);
326
321
  const { roomId } = split;
327
- const { conversationId, text } = { conversationId: posted, text: this.addressed(roomId, posted, String(content.text ?? '')).text };
322
+ const text = String(content.text ?? '');
328
323
  const res = await this.fetch(`${this.apiUrl()}/api/v3/rooms/${roomId}/conversations/${conversationId}/messages/${encodeURIComponent(messageId)}`, { method: 'PATCH', headers: this.headers(), body: JSON.stringify({ body: text.slice(0, MAX_BODY) }) });
329
324
  if (!res.ok) throw new Error(`${this.platform}: edit -> HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
330
325
  return { ok: true, message_id: messageId };
@@ -0,0 +1,116 @@
1
+ // `supercode-orchestrator agent-channel --agent <name>`: an agent's account Room, carried to its one mailbox
2
+ // (docs/adr/0008-agent-mailbox.md, decisions 24-25). The Room sits on the organization's floor (the RH2 bot's
3
+ // organization), with the agent as its one participant: every line a person posts there is filed as mail to the agent,
4
+ // and every answer is posted back anchored at its thread's root, by `AgentMailboxBridge` over the RH2 adapter.
5
+ //
6
+ // The Room is found by its key (`--room-key`, default `account-<agent>`), or opened once when the organization has
7
+ // none: the principal (`--principal`, RH2's principal id) as its operator, and the bot seated in its conversation
8
+ // answering every line. `--room <id>` names an existing Room instead and opens nothing.
9
+ //
10
+ // RH2 is reached as the agent's own principal where the organization declares the agent (docs/adr/0017: `supercode
11
+ // orchestrator apps install rh2-agent … --profile <agent>` in the home, `--root` or HERMES_HOME), so its threads, its DM
12
+ // and its call are one participant; otherwise through the Room-chat app's installation for this agent (`apps install
13
+ // room-chat … --profile <agent>`). The record's origin, organization, principal and token are the agent's identity in
14
+ // the Room. The bridge's thread anchors are kept in `--state`.
15
+ //
16
+ // The agent's DM in its Room is its main session (RFC 0020 decision 26): where this machine is in a Teams team, the
17
+ // participant's seat is bound to the main session (`supercode sessions bind`, the session owner's view binding for the
18
+ // Room's DM with that participant), and rebound to each new main session, as the Agents → Room agents app binds an
19
+ // arc's agent. Until the main session is in the team's catalog the binding is retried.
20
+ import { parseArgs } from 'node:util';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
25
+ import { Rh2Adapter } from '../adapters/rh2.mjs';
26
+ import { AgentMailboxBridge } from '../adapters/agent-mailbox.mjs';
27
+ import { installedApps } from '../installed-apps.mjs';
28
+
29
+ const { values } = parseArgs({
30
+ args: process.argv.slice(3),
31
+ options: { agent: { type: 'string' }, root: { type: 'string' }, room: { type: 'string' }, 'room-key': { type: 'string' }, 'room-name': { type: 'string' }, principal: { type: 'string' }, state: { type: 'string' }, help: { type: 'boolean' } },
32
+ });
33
+ if (values.help || !values.agent) {
34
+ console.log('supercode-orchestrator agent-channel --agent <name> [--root <home>] [--room <id> | --room-key <key> --principal <rh2 principal id> [--room-name <name>]] [--state <file>]');
35
+ process.exit(values.help ? 0 : 2);
36
+ }
37
+ const log = (level, line) => (level === 'debug' ? undefined : console.error(`${new Date().toISOString()} ${level} ${line}`));
38
+ const root = values.root ?? process.env.HERMES_HOME;
39
+ const apps = root ? installedApps(root).filter((app) => app.profile === values.agent) : [];
40
+ const installation = apps.find((app) => app.app === 'rh2-agent') ?? apps.find((app) => app.app === 'room-chat');
41
+ if (!installation) {
42
+ console.error(`agent-channel: ${values.agent} has no RH2 identity in ${root ?? '(no home: --root or HERMES_HOME)'}; install its agent (supercode orchestrator apps install rh2-agent … --profile ${values.agent}) or Room chat for it (apps install room-chat … --profile ${values.agent})`);
43
+ process.exit(1);
44
+ }
45
+ const identity = { api_url: installation.origin, organization: installation.organizationId, principal_id: installation.principalId, token: installation.token };
46
+ const adapter = new Rh2Adapter({ log, channel: { enabled: true, extra: identity } });
47
+ /** One RH2 write, its `data` returned; a refusal throws with its status. */
48
+ async function post(path, body) {
49
+ const res = await adapter.post(path, body);
50
+ if (!res.ok) throw new Error(`POST ${path} -> HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
51
+ return (await res.json()).data ?? {};
52
+ }
53
+
54
+ /** The account Room: named, found by its key, or opened once. */
55
+ async function accountRoom() {
56
+ if (values.room) return values.room;
57
+ const key = values['room-key'] ?? `account-${values.agent}`;
58
+ const listing = await adapter.get('/rooms');
59
+ const found = (Array.isArray(listing) ? listing : listing.rooms ?? []).map((entry) => entry.room ?? entry).find((room) => room?.key === key && room.state === 'active');
60
+ if (found) return String(found.roomId);
61
+ if (!values.principal) throw new Error(`no active Room with key ${key}, and no --principal to open it for`);
62
+ const { room } = await post('/rooms', {
63
+ key,
64
+ name: values['room-name'] ?? `${values.agent}`,
65
+ members: [{ principalId: values.principal, roleKeys: ['operator'] }],
66
+ });
67
+ const view = await adapter.get(`/rooms/${encodeURIComponent(room.roomId)}`);
68
+ const conversation = view.room?.defaultConversationId;
69
+ if (!conversation) throw new Error(`Room ${room.roomId} was opened without a conversation`);
70
+ await post(`/rooms/${encodeURIComponent(room.roomId)}/conversations/${encodeURIComponent(conversation)}/participants`, { answers: 'all', principalId: adapter.principalId() });
71
+ log('info', `opened the account Room ${room.roomId} (${key}) for ${values.agent}`);
72
+ return String(room.roomId);
73
+ }
74
+
75
+ const roomId = await accountRoom();
76
+ // The agent answers every line of its Room's conversation (the Room shows it as the conversation's agent); a seat it
77
+ // already holds answers 409.
78
+ {
79
+ const view = await adapter.get(`/rooms/${encodeURIComponent(roomId)}`);
80
+ const conversation = view.room?.defaultConversationId;
81
+ if (conversation) {
82
+ const res = await adapter.post(`/rooms/${encodeURIComponent(roomId)}/conversations/${encodeURIComponent(conversation)}/participants`, { answers: 'all', principalId: adapter.principalId() });
83
+ if (!res.ok && res.status !== 409) log('warn', `could not answer every line of ${conversation}: HTTP ${res.status}`);
84
+ }
85
+ }
86
+ // The adapter follows only this Room.
87
+ adapter.channel = { enabled: true, extra: { ...identity, rooms: [roomId] } };
88
+ const client = new SupercodeHarnessClient(process.env.SUPERCODE_BIN ? { command: process.env.SUPERCODE_BIN } : {});
89
+ const bridge = new AgentMailboxBridge({
90
+ adapter, client, agent: values.agent, fromName: `rh2-${values.agent}`, log,
91
+ statePath: values.state ?? join(homedir(), '.config', 'supercode', 'agent-channels', `${values.agent}.json`),
92
+ });
93
+ await bridge.start();
94
+ log('info', `${values.agent}: the account Room ${roomId} is carried to its mailbox`);
95
+
96
+ const supercode = process.env.SUPERCODE_BIN || 'supercode';
97
+ const sc = (args) => JSON.parse(execFileSync(supercode, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
98
+ let boundTo = null;
99
+ /** The participant's seat bound to the agent's current main session; a new main session rebinds it. */
100
+ async function bindDm() {
101
+ let main;
102
+ try { main = [sc(['agent', 'show', values.agent, '--json'])].flat().find((a) => a.name === values.agent)?.main_session; } catch { return; }
103
+ const session = typeof main === 'string' ? main.split(':').pop() : null;
104
+ if (!session || session === boundTo) return;
105
+ let bound;
106
+ try { bound = sc(['sessions', 'bind', session, '--room', roomId, '--agent', adapter.principalId(), '--rh2', adapter.apiUrl(), '--json']).data; }
107
+ catch (error) { log('debug', `${values.agent}: its DM is not bound yet (${String(error.stderr ?? error.message).trim().split('\n').at(-1)})`); return; }
108
+ await post(`/rooms/${encodeURIComponent(roomId)}/members`, { principalId: adapter.principalId(), roleKeys: ['member'], session: { harness: bound.harness, sessionId: bound.sessionId, team: bound.team, teamsUrl: bound.teamsUrl } });
109
+ boundTo = session;
110
+ log('info', `${values.agent}: its DM in the account Room is its main session ${session}`);
111
+ }
112
+ await bindDm();
113
+ const rebinding = setInterval(() => { void bindDm().catch((error) => log('warn', `${values.agent}: binding its DM: ${error.message}`)); }, 15_000);
114
+ const stop = async () => { clearInterval(rebinding); await bridge.stop(); await client.close?.(); process.exit(0); };
115
+ process.on('SIGTERM', stop);
116
+ process.on('SIGINT', stop);
@@ -8,6 +8,8 @@ if (process.argv[2] === 'workflow') {
8
8
  finally {await closeWorld();}
9
9
  await Promise.all([process.stdout,process.stderr].map(stream=>new Promise(done=>stream.write('',done))));
10
10
  process.exit(code);
11
+ } else if (process.argv[2] === 'agent-channel') {
12
+ await import('./agent-channel.mjs');
11
13
  } else {
12
14
  await import('./runtime.mjs');
13
15
  }
package/bin/runtime.mjs CHANGED
@@ -55,7 +55,7 @@
55
55
  import { SenderIdentityRecord } from '@volter/teams/sender-identity';
56
56
  import { existsSync, mkdirSync, rmdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
57
57
  import { basename, dirname, join, resolve } from 'node:path';
58
- import { spawn } from 'node:child_process';
58
+ import { spawn, spawnSync } from 'node:child_process';
59
59
  import { fileURLToPath } from 'node:url';
60
60
  import { homedir } from 'node:os';
61
61
  import { LOCK_FILE, leaseIsLive, readLease, thisHost } from '../lease.mjs';
@@ -70,7 +70,7 @@ import {
70
70
  TICK_MS, accessPolicy, applyOperator, performRemoveProfileDir, OPERATOR_OPS,
71
71
  } from '../index.mjs';
72
72
  import * as events from '../events.mjs';
73
- import { appView, installApp, installedApps, removeApp } from '../installed-apps.mjs';
73
+ import { appView, installAgent, installApp, installedApps, removeApp } from '../installed-apps.mjs';
74
74
  import { httpJobExecutor } from '../hosted.mjs';
75
75
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
76
76
 
@@ -356,6 +356,9 @@ function buildAdapters(state, vault = new Map(), log = () => {}, root = null) {
356
356
  // installed for. Uninstalling on the other side revokes the token and the channel stops.
357
357
  for (const app of root ? installedApps(root) : []) {
358
358
  if (app.app !== 'room-chat') continue;
359
+ // An installation for a mail agent (its name as the profile) is carried to the agent's mailbox by its own channel
360
+ // (`agent-channel`, docs/adr/0008-agent-mailbox.md): its Room's lines are the agent's mail, never a worker's turns.
361
+ if (mailAgentNamed(app.profile)) { report.push({ platform: 'rh2', profile: app.profile, app: app.name, state: 'carried by the agent\'s channel' }); continue; }
359
362
  if (!state.profiles[app.profile]) { report.push({ platform: 'rh2', profile: app.profile, app: app.name, state: 'unsupported (its profile does not exist)' }); continue; }
360
363
  if (adapters.rh2) { report.push({ platform: 'rh2', profile: app.profile, app: app.name, state: 'already owned by another profile' }); continue; }
361
364
  adapters.rh2 = new Rh2Adapter({ platform: 'rh2', profile: app.profile, log, vault, channel: { enabled: true, extra: { api_url: app.origin, organization: app.organizationId, principal_id: app.principalId, token: app.token } } });
@@ -364,6 +367,12 @@ function buildAdapters(state, vault = new Map(), log = () => {}, root = null) {
364
367
  return { adapters, report };
365
368
  }
366
369
 
370
+ /** Whether `name` is a declared mail agent on this machine (`supercode agent show`). */
371
+ function mailAgentNamed(name) {
372
+ const shown = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['agent', 'show', name, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
373
+ try { return shown.status === 0 && [JSON.parse(shown.stdout)].flat().some((agent) => agent?.name === name); } catch { return false; }
374
+ }
375
+
367
376
  function profileReport(name, profile) {
368
377
  return {
369
378
  profile: name,
@@ -445,7 +454,9 @@ const ACCESS_OPS = {
445
454
  * `access <verb> ...`: the COLD path. Access lives in Hermes's stores, so each verb is performed through
446
455
  * Hermes's own pairing store and writers, then the home is loaded again to report what stands.
447
456
  */
448
- /** `apps install NAME --origin URL --client-id ID --client-secret-file FILE --code CODE [--profile P]`, `apps list`, `apps remove NAME`. */
457
+ /** `apps install NAME --origin URL --client-id ID --client-secret-file FILE --code CODE [--profile P]`,
458
+ * `apps install rh2-agent --origin URL --organization ORG --principal PRN --token-file FILE --profile AGENT`, `apps list`,
459
+ * `apps remove NAME`. */
449
460
  async function appsMain(argv) {
450
461
  const [verb, ...rest] = argv;
451
462
  const flags = {}, positional = [];
@@ -456,6 +467,12 @@ async function appsMain(argv) {
456
467
  try {
457
468
  if (verb === 'list') { say('apps', { root, apps: installedApps(root).map(appView) }); return; }
458
469
  if (verb === 'remove') { say('apps', { root, removed: removeApp(root, positional[0] ?? '') }); return; }
470
+ if (verb === 'install' && positional[0] === 'rh2-agent') {
471
+ for (const name of ['origin', 'organization', 'principal', 'token-file', 'profile']) if (!flags[name]) throw new Error(`apps install rh2-agent needs --${name}`);
472
+ const token = readFileSync(flags['token-file'], 'utf8').trim();
473
+ say('apps', { root, installed: appView(await installAgent({ root, origin: flags.origin, organization: flags.organization, principal: flags.principal, token, profile: flags.profile })) });
474
+ return;
475
+ }
459
476
  if (verb === 'install') {
460
477
  for (const name of ['origin', 'client-id', 'client-secret-file', 'code']) if (!flags[name]) throw new Error(`apps install needs --${name}`);
461
478
  const clientSecret = readFileSync(flags['client-secret-file'], 'utf8').trim();
package/board/ci.mjs CHANGED
@@ -96,7 +96,8 @@ export function reviewCandidate(board, id) {
96
96
 
97
97
  /** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
98
98
  export const candidatePullRequests = (ci) => ci?.prs ?? (ci?.pr ? [{ repository: ci.repository, branch: ci.branch, commit: ci.commit, source_sha: ci.source_sha ?? ci.commit, pr: ci.pr }] : []);
99
- const originOf = (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1] ?? null;
99
+ // The repository a checkout declares (remote.origin.url), not where a URL rewrite sends its transport.
100
+ const originOf = (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['config', '--get', 'remote.origin.url'], path))?.[1] ?? null;
100
101
 
101
102
  function repositoryWorkspace(board, id) {
102
103
  const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
@@ -354,7 +355,7 @@ export function workflowRevision(root) {
354
355
  const workflow = realpathSync(join(root, 'workflow.yaml'));
355
356
  const checkout = run('git', ['rev-parse', '--show-toplevel'], dirname(workflow));
356
357
  const head = run('git', ['rev-parse', 'HEAD'], checkout);
357
- const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], checkout))?.[1];
358
+ const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['config', '--get', 'remote.origin.url'], checkout))?.[1];
358
359
  const name = relative(checkout, workflow).replaceAll('\\', '/');
359
360
  const source = readFileSync(workflow, 'utf8').trimEnd();
360
361
  if (run('git', ['show', `${head}:${name}`], checkout) !== source) return null;
package/board/cli.mjs CHANGED
@@ -8,7 +8,7 @@ import { taskStatusOf } from './published.mjs';
8
8
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { basename, join, resolve } from 'node:path';
11
- import { tmpdir, userInfo } from 'node:os';
11
+ import { homedir, tmpdir, userInfo } from 'node:os';
12
12
  import { read } from '../orchestration.mjs';
13
13
  import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
14
14
  import { isLocalWorkspace } from './workspace.mjs';
@@ -1243,6 +1243,17 @@ function ownCard(db, sessionId) {
1243
1243
 
1244
1244
  const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
1245
1245
 
1246
+ /** The main session of the agent an address names (`sc:<machine>:agent:<name>`, supercode docs/adr/0008-agent-mailbox.md),
1247
+ * from its record in the machine's mail root; null for any other address. A manager named by its agent address is
1248
+ * that agent's main session on the board. */
1249
+ function agentMain(address) {
1250
+ const name = /^sc:[^:\s]+:agent:([^:\s]+)$/.exec(address)?.[1];
1251
+ if (!name) return null;
1252
+ const root = process.env.SUPERCODE_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode');
1253
+ try { return String(JSON.parse(readFileSync(join(root, 'mail', 'agents', `${name}.json`), 'utf8')).main_session ?? '') || null; }
1254
+ catch { return null; }
1255
+ }
1256
+
1246
1257
  /**
1247
1258
  * Who a caller is on card `cardId`, in the workflow's roles: a signed-in terminal person retains their creator role;
1248
1259
  * the manager acts from its named session; the session of the card's current run has that run's role; the card's working session between runs
@@ -1251,7 +1262,7 @@ const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', '
1251
1262
  function rolesFor(db, cardId, sessionId, params = {}) {
1252
1263
  // a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
1253
1264
  const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
1254
- if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
1265
+ if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`) || agentMain(m)?.endsWith(`:${sessionId}`))) return ['manager'];
1255
1266
  const task = engine.getTask(db, cardId);
1256
1267
  if (!task) return [];
1257
1268
  const roles = [];
@@ -1998,7 +1998,7 @@ function readMailbox(board, address, report) {
1998
1998
  try { renameSync(path, join(box, 'cur', name)); } catch { continue; } // another reader took it
1999
1999
  // an answer to a question the board asked is what a card waits on (read by the card's workflow), not a verb
2000
2000
  if (envelope.in_reply_to) continue;
2001
- if (envelope.from === `sc:${localMachine()}:operator:teams-events` && json(envelope.body, {})?.machine_event) { report.mailed.push([envelope.from, 'machine_event']); continue; }
2001
+ if (envelope.from === `sc:${localMachine()}:operator:teams-events` && envelope.kind === 'notice') { report.mailed.push([envelope.from, 'machine_event']); continue; }
2002
2002
  const argv = json(envelope.body, {})?.workflow;
2003
2003
  let answer;
2004
2004
  if (!Array.isArray(argv) || !argv.length || NOT_BY_MAIL.has(argv[0])) answer = `not applied: a mailed card update is one \`supercode workflow\` verb other than ${[...NOT_BY_MAIL].join(', ')}`;
package/board/store.mjs CHANGED
@@ -174,6 +174,9 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
174
174
  if (!kept) mkdirSync(dirname(path), { recursive: true });
175
175
  const db = new DatabaseSync(path);
176
176
  db.exec('PRAGMA foreign_keys = OFF');
177
+ // Every statement waits out another process's write instead of failing: the store keeps a rollback journal, so a read
178
+ // outside a transaction (a verb resolving its caller) is refused while the dispatcher writes ("database is locked").
179
+ db.exec('PRAGMA busy_timeout = 10000');
177
180
  // The check and the layout are one write transaction: a board another process is laying out is not mistaken for a
178
181
  // laid-out one (its `tasks` table made, the rest not), and a layout cut short by that process leaves nothing behind.
179
182
  for (;;) {
package/index.d.ts CHANGED
@@ -320,6 +320,31 @@ export class ApiServerAdapter implements ChannelAdapter {
320
320
  export const API_SERVER_HOST: string;
321
321
  export const API_SERVER_PORT: number;
322
322
 
323
+ /**
324
+ * A channel carried to an agent's one mailbox (supercode docs/adr/0008-agent-mailbox.md):
325
+ * each person's line is filed as channel mail to the agent (a root to its main session,
326
+ * a reply into its parent's thread), and the agent's answers, filed in this bridge's
327
+ * mailbox, are posted back anchored at their thread's root.
328
+ */
329
+ export class AgentMailboxBridge {
330
+ constructor(o: {
331
+ adapter: ChannelAdapter;
332
+ /** A harness client (`SupercodeHarnessClient`): `messageSession` and `sessionInbox`. */
333
+ client: unknown;
334
+ /** The agent's name (`sc:<machine>:agent:<name>`). */
335
+ agent: string;
336
+ platform?: Platform;
337
+ /** This bridge's mailbox, a participant of every thread it opens. */
338
+ fromName?: string;
339
+ /** Where each thread's root was posted, and each posted answer's root, kept across restarts. */
340
+ statePath?: string | null;
341
+ log?: (level: string, message: string) => void;
342
+ pollMs?: number;
343
+ });
344
+ start(): Promise<void>;
345
+ stop(): Promise<void>;
346
+ }
347
+
323
348
  export class Orchestrator {
324
349
  constructor(o: {
325
350
  state: OrchestratorState;
package/index.mjs CHANGED
@@ -22,6 +22,7 @@ export { LoopbackAdapter } from './adapters/loopback.mjs';
22
22
  export { ChatSdkAdapter, toInbound, toAttachment, chatType, CHAT_SDK_VERSION, CHAT_SDK_PACKAGE, CHAT_SDK_STATE_PACKAGE } from './adapters/chat-sdk.mjs';
23
23
  export { ApiServerAdapter, DEFAULT_HOST as API_SERVER_HOST, DEFAULT_PORT as API_SERVER_PORT } from './adapters/api-server.mjs';
24
24
  export { Rh2Adapter } from './adapters/rh2.mjs';
25
+ export { AgentMailboxBridge } from './adapters/agent-mailbox.mjs';
25
26
  export { TOOLS, TOOL_NAMES, SERVER_NAME, PROTOCOL_VERSION } from './mcp/tools.mjs';
26
27
  export { mountOrchestratorMcp, doorFor, MCP_DOORS, SERVER_PATH } from './mcp/mount.mjs';
27
28
  export { startBridge } from './mcp/bridge.mjs';
@@ -10,9 +10,12 @@ export const APPS_DIR = 'apps';
10
10
  /** The apps this orchestrator can host, by name: what each installs and which adapter it attaches. */
11
11
  export const INSTALLABLE_APPS = Object.freeze({
12
12
  'room-chat': { side: 'rh2', adapter: 'rh2', describe: 'Room chat → orchestrator: a person\'s line in an RH2 Room reaches the agent seated there' },
13
+ // An agent the RH2 organization declares is one participant there: its chat, its DM and its call are its own agent
14
+ // principal, never an automation beside it. One record per agent (`rh2-agent.<agent>`).
15
+ 'rh2-agent': { side: 'rh2', adapter: 'rh2', perProfile: true, describe: 'An agent the RH2 organization declares, in its Rooms as its own principal' },
13
16
  });
14
17
 
15
- const recordPath = (root, name) => join(root, APPS_DIR, `${name}.json`);
18
+ const recordPath = (root, name, profile) => join(root, APPS_DIR, INSTALLABLE_APPS[name]?.perProfile ? `${name}.${profile}.json` : `${name}.json`);
16
19
 
17
20
  export function installedApps(root) {
18
21
  const dir = join(root, APPS_DIR);
@@ -20,7 +23,25 @@ export function installedApps(root) {
20
23
  return readdirSync(dir).filter((file) => file.endsWith('.json')).map((file) => {
21
24
  const record = JSON.parse(readFileSync(join(dir, file), 'utf8'));
22
25
  return { ...record, name: file.slice(0, -5) };
23
- }).filter((record) => INSTALLABLE_APPS[record.name]);
26
+ }).filter((record) => INSTALLABLE_APPS[record.app ?? record.name]);
27
+ }
28
+
29
+ /** An RH2 agent's own identity for a mail agent: the token the organization's admin issued that agent principal (RH2's
30
+ * agent-token door), kept once RH2 answers it in that organization. */
31
+ export async function installAgent({ root, origin, organization, principal, token, profile, fetchImpl = globalThis.fetch }) {
32
+ if (!profile) throw new Error('an RH2 agent is installed for a profile (--profile <agent>)');
33
+ if (!/^prn_/.test(String(principal))) throw new Error('--principal is the RH2 agent\'s principal id (prn_…)');
34
+ if (!/^https:\/\/|^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(String(origin))) throw new Error('the RH2 origin is an https URL');
35
+ const base = new URL(origin).origin;
36
+ const response = await fetchImpl(`${base}/api/v3/rooms`, { headers: { authorization: `Bearer ${token}`, 'x-rh2-organization': organization, origin: base, accept: 'application/json' } });
37
+ if (!response.ok) throw new Error(`RH2 refused the agent's token in ${organization}: HTTP ${response.status}`);
38
+ const record = { app: 'rh2-agent', side: 'rh2', origin: base, organizationId: organization, principalId: principal, profile, token, installedAt: new Date().toISOString() };
39
+ mkdirSync(join(root, APPS_DIR), { recursive: true, mode: 0o700 });
40
+ const path = recordPath(root, 'rh2-agent', profile);
41
+ const pending = `${path}.${process.pid}.tmp`;
42
+ writeFileSync(pending, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
43
+ renameSync(pending, path);
44
+ return { ...record, name: `rh2-agent.${profile}`, token: undefined };
24
45
  }
25
46
 
26
47
  /** Exchange an RH2 install code for the installation's token and keep the record. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@volter/supercode-harness-sdk": "^0.3.41",
28
- "@volter/teams": "0.2.5",
28
+ "@volter/teams": "0.2.6",
29
29
  "@volter/ztrack": "^2.4.23",
30
30
  "yaml": "^2.9.0"
31
31
  },