@volter/supercode-orchestrator 0.4.160 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -301,11 +301,22 @@ platforms:
301
301
  key: ${API_SERVER_KEY}
302
302
  ```
303
303
 
304
- RH2 integration attaches through an admin-consented app installation. A hand-written
305
- `platforms: rh2` block grants no integration authority and the runtime reports it as
306
- unsupported. The reusable RH2 transport remains in `adapters/rh2.mjs`; the Room-chat
307
- module supplies its app-owned settings and credentials when that module is installed.
308
- Native Hermes channel configuration remains the worker's configuration.
304
+ RH2 Room chat is an installed app ([ADR 0017](../../docs/adr/0017-room-chat-is-an-installed-app.md)).
305
+ Register the Room-chat app in the RH2 organization (`organization.read`, `room.read`; a Room's
306
+ manager seats its principal where it should answer), have an admin install it, and exchange the
307
+ code the install returns:
308
+
309
+ ```sh
310
+ supercode orchestrator apps install room-chat --origin https://pilot.runhuman.com \
311
+ --client-id app_… --client-secret-file ./room-chat.secret --code … --profile default
312
+ supercode orchestrator apps list
313
+ supercode orchestrator apps remove room-chat
314
+ ```
315
+
316
+ The record (with the installation's token) is kept owner-only under `<home>/apps/`; the next
317
+ daemon start attaches the `rh2` channel for that profile. Uninstalling in RH2 revokes the token and
318
+ the channel stops. A `platforms: rh2` block grants no integration authority and the runtime reports
319
+ it as unsupported. Native Hermes channel configuration remains the worker's configuration.
309
320
 
310
321
  **The Chat SDK sidecar** mounts `chat@4.39.0` (npm package `chat`, repo `vercel/chat`; a
311
322
  devDependency of this package, installed alongside `@chat-adapter/state-memory@4.39.0`). The
@@ -196,7 +196,10 @@ export class ChatSdkAdapter {
196
196
  async credentials() {
197
197
  const slug = String(this.extra('slug', this.platform));
198
198
  const hermes = HERMES_PLATFORMS[slug];
199
- if (hermes && !Object.keys(this.channel?.credentials ?? {}).length) {
199
+ // A block naming only Hermes's own variables (as the loader fills them in) is Hermes's block: Hermes's transport
200
+ // and its whole variable set, never a partial credential set in the package's default mode.
201
+ const refs = Object.values(this.channel?.credentials ?? {});
202
+ if (hermes && refs.every((ref) => Object.values(hermes.env).includes(ref.dotenv ?? ref.env))) {
200
203
  // Hermes's own variables, from the owning profile's own .env only (a
201
204
  // named profile's token never serves the root's block, Hermes #59739)
202
205
  const file = this.profileDir ? join(this.profileDir, '.env') : null;
package/adapters/rh2.mjs CHANGED
@@ -16,9 +16,8 @@
16
16
  // PATCH /rooms/:room/conversations/:c/messages/:id {body} — a streamed reply grows, a progress line updates, by edit
17
17
  // POST /rooms/:room/conversations/:c/presence {state: "typing"} — best effort, forgotten on 404
18
18
  //
19
- // This is reusable transport code. The daemon does not attach it from a
20
- // platforms block; an installed integration module owns its settings and token.
21
- // See docs/adr/0006-teams-installable-apps.md (C1) and the Room-chat module (M4).
19
+ // The Room-chat app (docs/adr/0017-room-chat-is-an-installed-app.md) attaches it with its RH2 installation's
20
+ // origin, organization, principal and token; a `platforms:` block never does.
22
21
  //
23
22
  // The surface key is `{platform: 'rh2', kind: 'channel', chat_id: '<roomId>'}`: a Room is one surface, so the agent it
24
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
@@ -67,31 +66,24 @@ export class Rh2Adapter {
67
66
  return e[`extra.${name}`] ?? e[name] ?? fallback;
68
67
  }
69
68
 
70
- /** The bearer: `credentials.token` resolved from the vault (a ref, never a value in the model). */
69
+ /** The bearer: the installation's token. */
71
70
  token() {
72
- const ref = this.channel?.credentials?.token ?? this.channel?.credentials?.['extra.token'];
73
- if (ref) {
74
- const name = ref.dotenv ?? ref.env;
75
- const value = this.vault.get(name) ?? (ref.env ? process.env[ref.env] : undefined);
76
- if (value === undefined) throw new Error(`platforms.${this.platform}.token: ref ${name} is not in the vault`);
77
- return value;
78
- }
79
- const direct = this.extra('token') ?? process.env.RH2_SESSION_TOKEN;
80
- if (!direct) throw new Error(`platforms.${this.platform}: no token (credentials.token or RH2_SESSION_TOKEN)`);
71
+ const direct = this.extra('token');
72
+ if (!direct) throw new Error(`${this.platform}: the installation carries no token`);
81
73
  return direct;
82
74
  }
83
75
 
84
- apiUrl() { return String(this.extra('api_url') ?? process.env.RH2_API_URL ?? '').replace(/\/+$/, ''); }
85
- organization() { return String(this.extra('organization') ?? process.env.RH2_ORGANIZATION ?? ''); }
86
- principalId() { return String(this.extra('principal_id') ?? process.env.RH2_PRINCIPAL_ID ?? ''); }
87
- pollMs() { const n = Number(this.extra('poll_seconds', process.env.RH2_POLL_SECONDS)); return Number.isFinite(n) && n > 0 ? Math.max(500, n * 1000) : DEFAULT_POLL_MS; }
76
+ apiUrl() { return String(this.extra('api_url') ?? '').replace(/\/+$/, ''); }
77
+ organization() { return String(this.extra('organization') ?? ''); }
78
+ principalId() { return String(this.extra('principal_id') ?? ''); }
79
+ pollMs() { const n = Number(this.extra('poll_seconds')); return Number.isFinite(n) && n > 0 ? Math.max(500, n * 1000) : DEFAULT_POLL_MS; }
88
80
  followedRooms() {
89
- const raw = this.extra('rooms') ?? process.env.RH2_ROOMS ?? '';
81
+ const raw = this.extra('rooms') ?? '';
90
82
  return (Array.isArray(raw) ? raw : String(raw).split(',')).map((s) => String(s).trim()).filter(Boolean);
91
83
  }
92
84
  headers() {
93
85
  let extra = {};
94
- const raw = this.extra('headers') ?? process.env.RH2_API_HEADERS;
86
+ const raw = this.extra('headers');
95
87
  if (raw) {
96
88
  try { extra = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { this.log('warn', `${this.platform}: headers is not a JSON object; ignored`); }
97
89
  }
@@ -125,7 +117,7 @@ export class Rh2Adapter {
125
117
 
126
118
  async connect(emit) {
127
119
  this.emit = emit;
128
- if (!this.apiUrl() || !this.organization()) throw new Error(`platforms.${this.platform}: api_url and organization are required`);
120
+ if (!this.apiUrl() || !this.organization()) throw new Error(`${this.platform}: the installation names no origin or organization`);
129
121
  this.token();
130
122
  await this.refreshRooms();
131
123
  this.running = true;
@@ -208,6 +200,12 @@ export class Rh2Adapter {
208
200
  await this.sleep(this.pollMs());
209
201
  } catch (error) {
210
202
  if (!this.running) return;
203
+ // The installation's token refused for good: the app was uninstalled in RH2, and its channel stops.
204
+ if (error.status === 401) {
205
+ this.running = false;
206
+ this.log('error', `${this.platform}: the installation's token was refused (uninstalled in RH2?); the channel stops: ${error.message}`);
207
+ return;
208
+ }
211
209
  failures += 1;
212
210
  const delay = Math.min(60_000, 2000 * 2 ** Math.min(failures, 5));
213
211
  this.log('warn', `${this.platform}: poll error ${error.message}; retrying in ${delay / 1000}s`);
package/agent-home.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // Configuration is a profile's template; Hermes's mutable memory and sessions belong to one agent.
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { linkTo, skillSources } from './hermes-home.mjs';
6
+
7
+ /** The immutable agent id keeps one home across sessions, profile changes and its move from local to an Org.
8
+ * Ids are minted globally unique, so the Org is checked but not part of the key. */
9
+ export function agentStateHome(root, org, id) {
10
+ if (!org || !id) throw new Error('agent state requires an Org and an immutable agent id');
11
+ const key = createHash('sha256').update(JSON.stringify(['agent', id])).digest('hex');
12
+ return join(resolve(root), '.agent-state', key, 'hermes');
13
+ }
14
+
15
+ /** Refresh only declared inputs. Never copy a profile's memories, session database or environment. */
16
+ export function materializeAgentHome(root, org, id, profile) {
17
+ const home = agentStateHome(root, org, id);
18
+ mkdirSync(home, { recursive: true, mode: 0o700 });
19
+ for (const name of ['config.yaml', 'AGENTS.md', '.mcp.json']) {
20
+ const source = join(profile.dir, name), target = join(home, name);
21
+ const contents = existsSync(source) ? readFileSync(source) : Buffer.from(name === 'AGENTS.md' ? '' : '{}\n');
22
+ if (existsSync(target) && readFileSync(target).equals(contents)) continue;
23
+ const temporary = `${target}.${randomUUID()}.tmp`;
24
+ writeFileSync(temporary, contents, { mode: 0o600 });
25
+ renameSync(temporary, target);
26
+ }
27
+ linkTo(join(home, 'SOUL.md'), 'AGENTS.md');
28
+ const skills = join(home, '.agents', 'skills');
29
+ mkdirSync(skills, { recursive: true, mode: 0o700 });
30
+ // These are template inputs. The agent's mutable state directories are never linked to a profile.
31
+ const inputs=skillSources(profile.dir);
32
+ for(const entry of readdirSync(skills,{withFileTypes:true}))if(entry.isSymbolicLink()&&!inputs.has(entry.name))unlinkSync(join(skills,entry.name));
33
+ for (const [name, source] of inputs) linkTo(join(skills, name), resolve(source));
34
+ return home;
35
+ }
@@ -0,0 +1,53 @@
1
+ // Profile intent is layered configuration. It never owns the agent's memory.
2
+ const object=value=>value!=null&&typeof value==='object'&&!Array.isArray(value);
3
+ const copy=value=>structuredClone(value);
4
+ const failure=message=>{throw new Error(message);};
5
+
6
+ /** Permission layers are retained separately: an evaluator must admit the action in every layer. */
7
+ export function resolveAgentProfile(profiles, name, overlays=[]) {
8
+ const seen=new Set(),chain=[];
9
+ function visit(id) {
10
+ if(seen.has(id))failure(`profile inheritance cycle at ${id}`);
11
+ const profile=profiles[id];if(!object(profile))failure(`profile ${id} does not exist`);
12
+ seen.add(id);if(profile.parent)visit(profile.parent);chain.push({name:id,value:profile});seen.delete(id);
13
+ }
14
+ visit(name);
15
+ for(const [index,value] of overlays.entries()){
16
+ if(!object(value))failure('a session profile overlay must be an object');
17
+ chain.push({name:`session:${index}`,value});
18
+ }
19
+ let resolved={},permissionLayers=[];
20
+ for(const {value} of chain){
21
+ if(Object.hasOwn(value,'memory'))failure('memory is agent state, not profile configuration');
22
+ resolved=merge(resolved,Object.fromEntries(Object.entries(value).filter(([key])=>key!=='permissions'&&key!=='parent')));
23
+ if(value.permissions!=null)permissionLayers.push(copy(value.permissions));
24
+ }
25
+ delete resolved.parent;resolved.permissions={layers:permissionLayers};
26
+ return {profile:name,layers:chain.map(x=>x.name),resolved};
27
+ }
28
+ function merge(parent,child){
29
+ const result=copy(parent);
30
+ for(const [key,value] of Object.entries(child)){
31
+ result[key]=object(value)&&object(result[key])?merge(result[key],value):copy(value);
32
+ }
33
+ return result;
34
+ }
35
+
36
+ /** A difference is evidence of actual execution, never an update to the intended profile. */
37
+ export function profileDifferences(intended, actual) {
38
+ const differences=[];
39
+ const visit=(left,right,path)=>{
40
+ if(object(left)&&object(right))for(const key of new Set([...Object.keys(left),...Object.keys(right)]))visit(left[key],right[key],path?`${path}.${key}`:key);
41
+ else if(JSON.stringify(left)!==JSON.stringify(right))differences.push({field:path,intended:left??null,actual:right??null});
42
+ };
43
+ visit(intended,actual,'');return differences;
44
+ }
45
+
46
+ /** Principal kinds never flow into an agent. A plain session remains plain absent a declaration/profile identity. */
47
+ export function sessionAgent({starter,declared=null,asAgent=null,profileAgent=null}) {
48
+ if(declared)return declared;
49
+ if(asAgent)return asAgent;
50
+ if(starter?.kind==='agent')return starter.id;
51
+ if(starter?.kind==='session')return starter.agent??null;
52
+ return profileAgent??null;
53
+ }