@volter/supercode-orchestrator 0.4.160 → 0.5.2

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.
@@ -0,0 +1,52 @@
1
+ // Installed integration apps of an orchestrator home (mirrored ontology M4, ADR 0017). An app reaches a profile only by
2
+ // an installation the other side consented to: its admin installs the app there, and the one-time code that consent
3
+ // returns is exchanged here, with the app's client secret, for the installation's token. The record lives in the home's
4
+ // `apps/` folder (owner-only); no `platforms:` block attaches an integration. Uninstalling on the other side revokes the
5
+ // token, and the channel stops; `remove` forgets the record here.
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ export const APPS_DIR = 'apps';
10
+ /** The apps this orchestrator can host, by name: what each installs and which adapter it attaches. */
11
+ export const INSTALLABLE_APPS = Object.freeze({
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
+ });
14
+
15
+ const recordPath = (root, name) => join(root, APPS_DIR, `${name}.json`);
16
+
17
+ export function installedApps(root) {
18
+ const dir = join(root, APPS_DIR);
19
+ if (!existsSync(dir)) return [];
20
+ return readdirSync(dir).filter((file) => file.endsWith('.json')).map((file) => {
21
+ const record = JSON.parse(readFileSync(join(dir, file), 'utf8'));
22
+ return { ...record, name: file.slice(0, -5) };
23
+ }).filter((record) => INSTALLABLE_APPS[record.name]);
24
+ }
25
+
26
+ /** Exchange an RH2 install code for the installation's token and keep the record. */
27
+ export async function installApp({ root, name, origin, clientId, clientSecret, code, profile = 'default', fetchImpl = globalThis.fetch }) {
28
+ const app = INSTALLABLE_APPS[name];
29
+ if (!app) throw new Error(`no installable app named ${name} (${Object.keys(INSTALLABLE_APPS).join(', ')})`);
30
+ if (!/^https:\/\/|^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(String(origin))) throw new Error('the RH2 origin is an https URL');
31
+ const base = new URL(origin).origin;
32
+ const response = await fetchImpl(`${base}/api/v3/automation/apps/oauth/access`, { method: 'POST', redirect: 'manual', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ clientId, clientSecret, code }) });
33
+ const payload = await response.json().catch(() => ({}));
34
+ if (!response.ok) throw Object.assign(new Error(payload?.error?.message ?? `the install code exchange answered ${response.status}`), { code: payload?.error?.code });
35
+ const { accessToken, expiresAt, installationId, organizationId, principalId } = payload.data;
36
+ const record = { app: name, side: app.side, origin: base, organizationId, principalId, installationId, expiresAt, profile, token: accessToken, installedAt: new Date().toISOString() };
37
+ mkdirSync(join(root, APPS_DIR), { recursive: true, mode: 0o700 });
38
+ const pending = `${recordPath(root, name)}.${process.pid}.tmp`;
39
+ writeFileSync(pending, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
40
+ renameSync(pending, recordPath(root, name));
41
+ return { ...record, token: undefined };
42
+ }
43
+
44
+ export function removeApp(root, name) {
45
+ const path = recordPath(root, name);
46
+ if (!existsSync(path)) return false;
47
+ rmSync(path);
48
+ return true;
49
+ }
50
+
51
+ /** What `apps list` shows: never the token. */
52
+ export const appView = (record) => ({ name: record.name, side: record.side, origin: record.origin, organization: record.organizationId, principal: record.principalId, installation: record.installationId, profile: record.profile, expires_at: record.expiresAt });
package/ir.mjs CHANGED
@@ -135,7 +135,7 @@ export function homeChannel(profile, platform = null) {
135
135
  return null;
136
136
  }
137
137
 
138
- export const END_REASONS = new Set(['idle', 'daily', 'reset', 'new', 'handoff', 'error']);
138
+ export const END_REASONS = new Set(['idle', 'daily', 'reset', 'new', 'handoff', 'transfer', 'left', 'error']);
139
139
 
140
140
  export const FIRE_STATUSES = new Set(['claimed', 'running', 'succeeded', 'failed', 'timeout', 'unknown']);
141
141
 
@@ -156,7 +156,7 @@ export function resolveRoute(routes, key, guildId) {
156
156
  if (m.thread_id && m.thread_id !== key.thread_id) continue;
157
157
  if (!best || routeSpecificity(r) > routeSpecificity(best)) best = r;
158
158
  }
159
- return best ? best.profile : null;
159
+ return best ? best.agent : null;
160
160
  }
161
161
 
162
162
  // ---------------------------------------------------------------- profiles / state
@@ -183,7 +183,7 @@ export function emptyRuntime() {
183
183
  }
184
184
 
185
185
  export function emptyState(root) {
186
- return { root, profiles: { default: emptyProfile('default', root) }, runtime: emptyRuntime() };
186
+ return { root, profiles: { default: emptyProfile('default', root) }, agents: {}, conversations: {}, transfers: [], handoffs: [], usage: {}, runtime: emptyRuntime() };
187
187
  }
188
188
 
189
189
  /** Stable, secret-free JSON of the persisted half of the state (runtime omitted). */
package/kanban-worker.mjs CHANGED
@@ -14,13 +14,14 @@ import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
14
14
  import { load } from './orchestration.mjs';
15
15
  import { INSTRUCTION_ARGUMENTS, activationCwd, activationEnv, sandboxArguments, approvalPolicy, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
16
16
  import { workerRoute } from './worker-route.mjs';
17
- import { KANBAN_ENV, untrustedServerVerdict, hermesInstall } from './hermes-cli.mjs';
17
+ import { KANBAN_ENV, untrustedServerVerdict, hermesInstall, memorySnapshot } from './hermes-cli.mjs';
18
18
  import { pinnedSkills, taskProtocol } from './board/worker-prompt.mjs';
19
19
  import { mountOrchestratorMcp } from './mcp/mount.mjs';
20
20
  import { normalizeWorkerEvent } from './loop.mjs';
21
21
  import { DEFAULT_BOARD, json, openBoard, tx } from './board/store.mjs';
22
22
  import { setRunSession } from './board/engine.mjs';
23
23
  import { permissionResponse } from './reducer.mjs';
24
+ import { boardGuardArguments } from '../teams/board-process-guard.mjs';
24
25
 
25
26
  /** Hermes's worker argv, as `_default_spawn` builds it. */
26
27
  export function parseHermesArgv(argv) {
@@ -50,11 +51,12 @@ export async function runKanbanWorker(argv, env = process.env) {
50
51
  const args = parseHermesArgv(argv);
51
52
  if (!env.HERMES_HOME || !args.query) { say('hermes-worker: HERMES_HOME and -q are required (the dispatcher sets both)'); return 2; }
52
53
  const home = resolve(env.HERMES_HOME);
53
- const root = rootOf(home);
54
+ const root = env.SUPERCODE_AGENT_HOME ? resolve(env.SUPERCODE_AGENT_HOME) : rootOf(home);
54
55
  const { state, vault } = await load(root);
55
56
  const profile = Object.values(state.profiles).find((p) => resolve(p.dir) === home) ?? state.profiles[args.profile ?? 'default'];
56
57
  if (!profile?.worker?.harness) { say(`hermes-worker: no worker for profile ${args.profile ?? home}`); return 2; }
57
- if (profile.worker.harness === 'hermes') return runHermes(argv, env);
58
+ // The agent's home is already the effective profile. Hermes must not switch back to the shared template.
59
+ if (profile.worker.harness === 'hermes') return runHermes(env.SUPERCODE_AGENT_ID?withoutProfile(argv):argv, env);
58
60
  const harness = profile.worker.harness;
59
61
  const task = Object.fromEntries(KANBAN_ENV.filter((k) => env[k]).map((k) => [k, env[k]]));
60
62
  const taskId = task.HERMES_KANBAN_TASK ?? 'none';
@@ -67,10 +69,12 @@ export async function runKanbanWorker(argv, env = process.env) {
67
69
  materializeSkills(profile);
68
70
  const route = workerRoute(profile, worker, vault);
69
71
  const surfaceKey = `kanban|task|${taskId}`;
70
- const runEnv = { HERMES_HOME: profile.dir, ...activationEnv({ root, profile, surfaceKey, vault }), ...route.env, ...task };
72
+ const identityEnv=Object.fromEntries(Object.entries(env).filter(([key])=>key.startsWith('SUPERCODE_AGENT_')));
73
+ const runEnv = { ...activationEnv({ root, profile, surfaceKey, vault }), ...route.env, ...task, ...identityEnv,HERMES_HOME: home };
74
+ const memory=env.SUPERCODE_AGENT_ID?await memorySnapshot(home):'';
71
75
  // the persona, the task protocol and the pinned skills, supercode's own (board/worker-prompt.mjs)
72
76
  // the board workflow's own words for this run (its status's prompt and doors), after the task protocol
73
- const instructions = [userHomePersona(profile), taskProtocol(taskId), env.SUPERCODE_BOARD_PROMPT ?? '', pinnedSkills(profile.dir, args.skills)].filter(Boolean).join('\n\n');
77
+ const instructions = [userHomePersona(profile), taskProtocol(taskId), env.SUPERCODE_BOARD_PROMPT ?? '', memory,pinnedSkills(profile.dir, args.skills)].filter(Boolean).join('\n\n');
74
78
 
75
79
  const client = new SupercodeHarnessClient(env.SUPERCODE_BIN ? { command: env.SUPERCODE_BIN } : {});
76
80
  try {
@@ -87,7 +91,7 @@ async function runTurn({ client, profile, harness, worker, route, root, vault, s
87
91
  const reasoning = args.reasoning && harness === 'codex' ? ['-c', `model_reasoning_effort=${JSON.stringify(args.reasoning)}`] : [];
88
92
  const launch = {
89
93
  program: base.program,
90
- arguments: [...(base.arguments ?? []), ...modelArguments(worker), ...route.args, ...(instructions && INSTRUCTION_ARGUMENTS[harness] ? INSTRUCTION_ARGUMENTS[harness](instructions) : []), ...userHomeArguments(profile), ...reasoning, ...sandboxArguments(profile, harness), ...limitArguments(profile, worker, (level, line) => say(line))],
94
+ arguments: boardGuardArguments(harness, [...(base.arguments ?? []), ...modelArguments(worker), ...route.args, ...(instructions && INSTRUCTION_ARGUMENTS[harness] ? INSTRUCTION_ARGUMENTS[harness](instructions) : []), ...userHomeArguments(profile), ...reasoning, ...sandboxArguments(profile, harness), ...limitArguments(profile, worker, (level, line) => say(line))], { enabled: ['claude-code','codex'].includes(harness), cwd }),
91
95
  env: { ...(base.env ?? {}), ...runEnv },
92
96
  };
93
97
  const mount = mountOrchestratorMcp({ root, profile, harness, surfaceKey, launch, vault, task });
@@ -162,3 +166,9 @@ function runHermes(argv, env) {
162
166
  child.on('error', () => done(1));
163
167
  });
164
168
  }
169
+
170
+ function withoutProfile(argv) {
171
+ const selected=[];
172
+ for(let i=0;i<argv.length;i+=1){if(argv[i]==='-p'||argv[i]==='--profile'){i+=1;continue;}selected.push(argv[i]);}
173
+ return selected;
174
+ }
package/loop.mjs CHANGED
@@ -12,7 +12,7 @@ import { read, save, retainWorld, releaseWorld } from './orchestration.mjs';
12
12
  import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, approvalPolicy, sandboxArguments, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
13
13
  import { workerRoute } from './worker-route.mjs';
14
14
  import { hermesAccess, hermesApproval, untrustedServerVerdict, hermesToolEnv, memorySnapshot, mirrorSession, mirrorState, recordIncident, sealMirror } from './hermes-cli.mjs';
15
- import { hermesSessionKey } from './ir.mjs';
15
+ import { hermesSessionKey, homeChannel } from './ir.mjs';
16
16
  import { surfaceKeyString } from './ir.mjs';
17
17
  import { BREAKER_FAILURES, BREAKER_MINUTES, MAX_ATTEMPTS, historyRetention, jobGate } from './cron.mjs';
18
18
  import { startWebhookListener } from './webhook.mjs';
@@ -23,6 +23,9 @@ import { performRemoveProfileDir } from './operator.mjs';
23
23
  import { harnessHomes } from './mcp/verbs.mjs';
24
24
  import { checkMonitor, runJobScript } from './gates.mjs';
25
25
  import { startBoard } from './board.mjs';
26
+ import { SenderIdentityRecord } from '@volter/teams/sender-identity';
27
+ import { agentDoor,agentScope,teamsUnavailable,ensureProfileAgent,localProfileAgent,localAgentRecord,localAgentSessions,synchronizeLocalAgents,ensureForkAgent,agentRunForKey,agentIntent,agentPreamble,agentEnvironment,recordAgentRun,recordNativeAgentSession,pendingAgentRuns,synchronizeAgentRun,agentRunForSession } from './agents.mjs';
28
+ import { materializeAgentHome } from './agent-home.mjs';
26
29
  import { localMachine } from './board/dispatch.mjs';
27
30
  import { execFile } from 'node:child_process';
28
31
  import { promisify } from 'node:util';
@@ -55,6 +58,7 @@ export class Orchestrator {
55
58
  this.state = state;
56
59
  this.executeJob = executeJob;
57
60
  this.hostedTasks = new Set();
61
+ this.identityRecord = new SenderIdentityRecord();
58
62
  state.runtime ??= {};
59
63
  state.runtime.hostedJobs = !!executeJob;
60
64
  state.runtime.machine = localMachine(); // a job naming another machine fires there (cron.mjs startFire)
@@ -183,11 +187,24 @@ export class Orchestrator {
183
187
  tick(at = this.clock()) {
184
188
  this.queue = this.queue.then(() => this.adoptOutsideJobs()).catch((error) => this.log('warn', `cron/jobs.json not read back: ${error.message}`));
185
189
  return this.dispatch(events.tick(at, cronInactivitySeconds(), cronMaxParallel())).then(
186
- (result) => { this.tickerHeartbeat(true); return result; },
190
+ (result) => { this.tickerHeartbeat(true); this.synchronizeProfileSessions();return result; },
187
191
  (error) => { this.tickerHeartbeat(false); throw error; },
188
192
  );
189
193
  }
190
194
 
195
+ synchronizeProfileSessions(){
196
+ if(!existsSync(join(this.state.root,'agent-state.sqlite')))return;
197
+ try{
198
+ const context=process.env.SUPERCODE_CONTEXT,scope=agentScope(context);
199
+ if(scope.offline)return;
200
+ for(const pending of synchronizeLocalAgents({root:this.state.root,profiles:this.state.profiles,context,declarations:this.state.agents??{}}))this.log('warn',`agent ${pending.agent} declaration pending: ${pending.error}`);
201
+ for(const row of pendingAgentRuns(this.state.root,scope.org)){
202
+ try{synchronizeAgentRun(this.state.root,scope.org,row,{context,intended:row.profile_json?JSON.parse(row.profile_json):undefined});}
203
+ catch(error){this.log('warn',`agent session ${row.session_ref}: ${error.message}`);}
204
+ }
205
+ }catch(error){this.log('warn',`agent session synchronization: ${error.message}`);}
206
+ }
207
+
191
208
  /**
192
209
  * Hermes's ticker liveness (`cron/jobs.py` `record_ticker_heartbeat`), in each profile's cron store: the
193
210
  * epoch of every tick in `ticker_heartbeat`, and of every tick that did not fail in `ticker_last_success`.
@@ -255,6 +272,12 @@ export class Orchestrator {
255
272
  }
256
273
 
257
274
  async apply(event) {
275
+ if(event.type==='inbound'&&!event.is_me) {
276
+ // An adapter's display name or supplied principal never proves identity.
277
+ const observation=`${event.key.platform}:${event.sender}`;
278
+ try {event={...event,identity:await this.identityRecord.observe({observation,location:JSON.stringify(event.key),at:Date.parse(event.at),name:event.sender_name,evidence:{adapter:event.adapter,message_id:event.message_id}})};}
279
+ catch(error){event={...event,identity:{observation,principal:null,classification:'unresolved',label:'unresolved'}};this.log('warn',`identity observation unavailable: ${error.message}`);}
280
+ }
258
281
  const effects = step(this.state, event);
259
282
  for (const effect of effects) {
260
283
  this.effectLog.push(effect);
@@ -292,7 +315,7 @@ export class Orchestrator {
292
315
  let got;
293
316
  try { got = hermesAccess(this.state.profiles[effect.profile].dir, 'issue', effect); } catch (error) { this.log('warn', `pairing: ${error.message}`); return; }
294
317
  if (got?.silent) return;
295
- await this.dispatch({ type: 'pairing_issued', profile: effect.profile, key: effect.key, code: got?.code ?? null, at: this.clock() });
318
+ this.enqueue({ type: 'pairing_issued', profile: effect.profile, key: effect.key, code: got?.code ?? null, at: this.clock() });
296
319
  return;
297
320
  }
298
321
  case 'access_write': {
@@ -300,7 +323,10 @@ export class Orchestrator {
300
323
  try {
301
324
  const got = hermesAccess(dir, effect.op, effect);
302
325
  if (effect.op === 'approve') {
303
- if (got) await this.dispatch({ type: 'operator', verb: 'access_granted', profile: effect.profile, args: { platform: got.platform, user_id: got.user_id }, at: this.clock() });
326
+ if (got) {
327
+ await this.identityRecord.approvedPairing({...got,code:effect.code,principal_id:effect.principal??null,location:dir}).catch(error=>this.log('warn',`pairing identity not recorded: ${error.message}`));
328
+ this.enqueue({ type: 'operator', verb: 'access_granted', profile: effect.profile, args: { platform: got.platform, user_id: got.user_id }, at: this.clock() });
329
+ }
304
330
  else this.log('warn', `pairing code ${effect.code}: not pending in Hermes's pairing store (wrong, expired, or locked out)`);
305
331
  }
306
332
  } catch (error) {
@@ -308,6 +334,8 @@ export class Orchestrator {
308
334
  }
309
335
  return;
310
336
  }
337
+ case 'usage_refresh': return this.refreshUsage(effect);
338
+ case 'budget_refused': return this.tellBudgetRefusal(effect);
311
339
  case 'save': await this.save(); return;
312
340
  case 'log': this.log(effect.level, effect.message); return;
313
341
  default: this.log('warn', `unknown effect ${effect.type}`);
@@ -371,7 +399,7 @@ export class Orchestrator {
371
399
  }
372
400
  // Hermes runs a no_agent script in the job's workdir, and an agent job's
373
401
  // pre-run script from the scripts folder
374
- const script = gate.script ? await runJobScript(profile, gate.script, gate.no_agent ? workdir : null, { signal, secrets }) : null;
402
+ const script = gate.script ? await runJobScript(profile, gate.script, gate.no_agent ? workdir : null, { signal, secrets, automation: `cron:${profile.name}/${job.id}` }) : null;
375
403
  return { monitor, script };
376
404
  })()
377
405
  .catch((error) => ({ script: { ok: false, output: `Script execution failed: ${error.message}` } }))
@@ -383,23 +411,70 @@ export class Orchestrator {
383
411
  /** Wait for admitted host tasks; useful for deterministic tests and graceful shutdown. */
384
412
  async drainHosted() { await Promise.allSettled([...this.hostedTasks]); }
385
413
 
386
- async startRuntime({ profile: profileName, keyString, worker, resume_runtime_id = null }) {
414
+ async startRuntime({ profile: profileName, keyString, worker, resume_runtime_id = null,agent_as=null,new_fork=false,run_key=null,starter_session=null,agent:agentName=null }) {
387
415
  const profile = this.state.profiles[profileName];
388
416
  const lid = leaseId(profileName, keyString);
389
417
  const cwd = activationCwd(profile, worker.cwd);
390
418
  materializePersona(profile);
391
419
  materializeSkills(profile);
392
- let launch;
420
+ let launch,agent,scope,context,agentPrompt,main,agentHome;
393
421
  try {
422
+ context=process.env.SUPERCODE_CONTEXT;scope=agentScope(context);
423
+ const local=error=>{if(!teamsUnavailable(error))throw error;this.log('warn',`agent identity for ${profileName} stays local: ${error.message}`);};
424
+ let previous=null,reader=null;
425
+ if(agent_as) {
426
+ // A launch as a named agent is an act Teams authorizes; it never proceeds on local intent alone.
427
+ agentDoor('management',[agent_as],{context});
428
+ const identity=agentDoor('get',[agent_as],{context}).data,intent=agentIntent(this.state.root,scope.org,agent_as);
429
+ if(!intent)throw new Error('launch as an agent requires its retained profile intent');
430
+ agent={identity,intended:intent.profile,assignments:intent.assignments,org:scope.org};
431
+ } else if(!scope.offline) {
432
+ try{agent=ensureProfileAgent({root:this.state.root,org:scope.org,server:scope.server,profiles:this.state.profiles,profile:profileName,context,agent:agentName,declarations:this.state.agents});}catch(error){local(error);}
433
+ }
434
+ // Situation 16: no reachable Teams. The locally minted identity runs now and is declared later under its id.
435
+ agent??=localProfileAgent({root:this.state.root,org:scope.org,server:scope.server,profiles:this.state.profiles,profile:profileName,agent:agentName,declarations:this.state.agents});
436
+ agent.intended.resolved.worker={...agent.intended.resolved.worker,...worker,cwd};
437
+ main=new_fork||!agent.identity.main_session;
438
+ if(agent.synced!==false){
439
+ try{
440
+ previous=agentDoor('sessions',[agent.identity.id,{limit:200}],{context}).items??[];
441
+ if(resume_runtime_id&&!new_fork) {
442
+ // a session no sync rule publishes is unknown to Teams: its owner is the local run record, read below
443
+ let resumed=null;
444
+ try{resumed=agentDoor('session',[`${worker.harness}:${resume_runtime_id}`],{context}).data;}catch(error){if(error.code!=='not_found')throw error;}
445
+ if(resumed){
446
+ if(resumed.agent?.id!==agent.identity.id)throw new Error('the resumed session is not owned by this profile agent');
447
+ main=resumed.main;
448
+ } else {
449
+ const owner=agentRunForSession(this.state.root,[`${worker.harness}:${resume_runtime_id}`]);
450
+ if(owner&&owner.agent_id!==agent.identity.id)throw new Error('the resumed session is not owned by this profile agent');
451
+ if(owner)main=owner.main===1;
452
+ }
453
+ }
454
+ reader=agentDoor('readerContext',[agent.identity.id,null],{context,timeout:60000});
455
+ }catch(error){local(error);}
456
+ }
457
+ if(resume_runtime_id&&!new_fork&&!previous){
458
+ const owner=agentRunForSession(this.state.root,[`${worker.harness}:${resume_runtime_id}`]);
459
+ if(owner&&owner.agent_id!==agent.identity.id)throw new Error('the resumed session is not owned by this profile agent');
460
+ if(owner)main=owner.main===1;
461
+ }
462
+ previous??=localAgentSessions(this.state.root,scope.org,agent.identity.id);
463
+ agentPrompt=agentPreamble(agent,{main,previous});
464
+ // a conversation passes between this home's agents by name (conversation_transfer); the model learns the names here
465
+ const peers=[...new Set([...Object.keys(this.state.profiles??{}),...Object.keys(this.state.agents??{})])].filter((name)=>name!==(agent.identity.name??profile.name)&&name!==profile.name);
466
+ if(peers.length)agentPrompt+=`\nThe other agents of this home: ${peers.join(', ')}. conversation_transfer hands a conversation to one of them by that name; they are not Claude Code sessions.`;
467
+ agentHome=materializeAgentHome(this.state.root,scope.org,agent.identity.id,profile);
394
468
  // the home's own `model:` is the worker's model and route (docs/plans/hermes-compat.md row 6)
395
469
  const route = workerRoute(profile, worker, this.vault);
396
470
  // a worker of any harness but Hermes gets Hermes's CLI against its profile's home (row 7)
397
- const tools = worker.harness === 'hermes' ? {} : hermesToolEnv(profile.dir);
398
- const env = { ...tools, ...activationEnv({ root: this.state.root, profile, surfaceKey: keyString, vault: this.vault }), ...route.env };
399
- // the home's memory, as Hermes's agent carries it into a session (row 13)
400
- const memory = INSTRUCTION_ARGUMENTS[worker.harness] ? await memorySnapshot(profile.dir).catch((error) => { this.log('warn', `memory ${keyString}: ${error.message}`); return ''; }) : '';
471
+ const tools = worker.harness === 'hermes' ? {} : hermesToolEnv(agentHome);
472
+ const env = { ...tools, ...activationEnv({ root: this.state.root, profile, surfaceKey: keyString, vault: this.vault }), ...route.env,...agentEnvironment(agent,{root:this.state.root,context:reader?.context,main}) };
473
+ env.HERMES_HOME=agentHome;
474
+ // All sessions of this agent carry its state; a shared profile contributes configuration only.
475
+ const memory = INSTRUCTION_ARGUMENTS[worker.harness] ? await memorySnapshot(agentHome).catch((error) => { this.log('warn', `memory ${keyString}: ${error.message}`); return ''; }) : '';
401
476
  // a worker in the user's own home carries its persona there too (its config home's CLAUDE.md is the user's)
402
- const instructionText = [userHomePersona(profile), memory].filter(Boolean).join('\n\n');
477
+ const instructionText = [agentPrompt,userHomePersona(profile), memory].filter(Boolean).join('\n\n');
403
478
  const instructions = instructionText && INSTRUCTION_ARGUMENTS[worker.harness] ? INSTRUCTION_ARGUMENTS[worker.harness](instructionText) : [];
404
479
  const naming = NAME_ARGUMENTS[worker.harness]?.(basename(this.state.root)) ?? [];
405
480
  launch = await this.resolveLaunch(profile, { ...worker, model: route.model }, env, [...route.args, ...instructions, ...naming, ...userHomeArguments(profile)]);
@@ -414,7 +489,7 @@ export class Orchestrator {
414
489
  socketPath: this.bridge?.path ?? socketPathFor(this.state.root), vault: this.vault,
415
490
  });
416
491
  this.log('info', `mcp mount (${worker.harness}, ${mount.door}): ${mount.ran}`);
417
- if (resume_runtime_id) resume_runtime_id = await this.continueFromHermes(profile, keyString, worker, resume_runtime_id, cwd);
492
+ if (resume_runtime_id) resume_runtime_id = await this.continueFromHermes(profile, keyString, worker, resume_runtime_id, cwd,agentHome);
418
493
  // read before the runtime exists: a runtime emits from the moment it is made
419
494
  const steer = Boolean((await this.descriptorFor(worker.harness).catch(() => null))?.runtime?.capabilities?.steer);
420
495
  let runtime;
@@ -426,9 +501,11 @@ export class Orchestrator {
426
501
  runtime = await this.client.resumeManagedRuntime({ ...params, runtime_id: resume_runtime_id });
427
502
  this.log('info', `resume ${keyString}: reopened ${resume_runtime_id}`);
428
503
  } catch (error) {
504
+ if(new_fork)throw error;
429
505
  this.log('warn', `resume ${keyString}: ${resume_runtime_id} did not reopen (${error.message}); starting fresh`);
430
506
  }
431
507
  }
508
+ if(new_fork&&!runtime)throw new Error('this harness cannot resume the materialized fork; no fresh replacement was started');
432
509
  runtime ??= await this.client.startManagedRuntime(params);
433
510
  } catch (error) {
434
511
  this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: null, ev: { kind: 'error', message: `start failed: ${error.message}` }, at: this.clock() }));
@@ -439,12 +516,51 @@ export class Orchestrator {
439
516
  const previous = this.runtimes.get(lid);
440
517
  if (previous && previous !== runtime) this.retired.set(previous.handle.runtime_id, previous);
441
518
  this.runtimes.set(lid, runtime);
519
+ if(!INSTRUCTION_ARGUMENTS[worker.harness])(this.runtimeAgentPreambles??=new Map()).set(lid,agentPrompt);
520
+ const session=`${worker.harness}:${runtime.handle.runtime_id}`;
521
+ recordAgentRun(this.state.root,scope.org,agent.identity.id,{session,machine:null,run:run_key??lid,profile:agent.intended.resolved,main,starterSession:starter_session});
522
+ if(agent.synced!==false)try{synchronizeAgentRun(this.state.root,scope.org,{agent_id:agent.identity.id,session_ref:session,machine:null,main},{context,intended:agent.intended.resolved});}
523
+ catch(error){this.log('warn',`agent session ${session} retained for synchronization: ${error.message}`);}
442
524
  this.enqueue(events.worker({
443
525
  profile: profileName, keyString, runtime_id: runtime.handle.runtime_id,
444
526
  ev: { kind: 'started', runtime_id: runtime.handle.runtime_id, session_id: runtime.handle.runtime_id, connection: runtime.connection, harness: worker.harness, steer },
445
527
  at: this.clock(),
446
528
  }));
447
529
  this.watch(profileName, keyString, runtime);
530
+ return runtime.handle;
531
+ }
532
+
533
+ /** Fork through native handoff/materialization. The source remains retained and the child has its own id. */
534
+ async forkRuntime({profile:profileName,keyString,parent,locator:given,name,key,machine=null}) {
535
+ if(machine)throw new Error('fork this owned workspace on its execution host; move it through the workspace door afterward');
536
+ const locator=await this.wholeLocator(given);
537
+ const profile=this.state.profiles[profileName];if(!profile)throw new Error('fork requires a named profile');
538
+ const binding=profile.bindings[keyString];
539
+ if(!binding||binding.ended_at)throw new Error('register the fork target conversation before starting its runtime');
540
+ if(this.runtimes.has(leaseId(profileName,keyString)))throw new Error('fork requires an unoccupied target conversation');
541
+ if(!(await this.descriptorFor(locator.harness))?.runtime?.capabilities?.resume_session)throw new Error('this harness cannot resume a materialized fork');
542
+ const context=process.env.SUPERCODE_CONTEXT,scope=agentDoor('scope',[],{context}),session=`${locator.harness}:${locator.session_id}`;
543
+ const child=ensureForkAgent({root:this.state.root,org:scope.org,server:scope.server,profiles:this.state.profiles,parent,session,name,key,context});
544
+ const runKey=`fork:${key}`,prior=agentRunForKey(this.state.root,scope.org,child.identity.id,runKey);
545
+ let target;
546
+ if(prior) {
547
+ const native=prior.session_ref.slice(prior.session_ref.indexOf(':')+1);
548
+ const page=await this.client.discover({harnesses:[locator.harness],query:native,limit:200});
549
+ target=page.sessions.find(item=>item.locator.harness===locator.harness&&item.locator.session_id===native)?.locator;
550
+ if(!target)throw new Error('retained fork session is not available; no replacement was created');
551
+ }else {
552
+ const loaded=await this.client.loadWindow(locator,{message_limit:1});
553
+ if(loaded.session.session_id!==locator.session_id)throw new Error('fork locator and native session identity differ');
554
+ const cwd=activationCwd(profile,child.intended.resolved.worker?.cwd),store=WORKER_STORE[locator.harness];
555
+ if(!store)throw new Error('this harness has no admitted native materialization door');
556
+ const {artifact}=await this.client.handoffSession({locator,target_harness:locator.harness,cwd});
557
+ const home=materializeAgentHome(this.state.root,scope.org,child.identity.id,profile);
558
+ target=(await this.client.materializeSession({artifact,cwd,homes:store(locator.harness==='hermes'?home:join(profile.dir,locator.harness))})).locator;
559
+ recordAgentRun(this.state.root,scope.org,child.identity.id,{session:`${target.harness}:${target.session_id}`,machine:null,run:runKey,profile:child.intended.resolved,main:true,starterSession:session});
560
+ }
561
+ const runtime=await this.startRuntime({profile:profileName,keyString,worker:{...profile.worker,...child.intended.resolved.worker,harness:target.harness},resume_runtime_id:target.session_id,agent_as:child.identity.id,new_fork:true,run_key:runKey,starter_session:session});
562
+ if(!runtime)throw new Error('the child session did not start; its declared identity and native fork remain retained');
563
+ return {agent:child.identity,locator:target,runtime};
448
564
  }
449
565
 
450
566
  /**
@@ -461,6 +577,26 @@ export class Orchestrator {
461
577
  this.enqueue(events.operator({ verb: 'binding_interrupted', profile: profileName, args: { keyString, runtime_id }, at: this.clock() }));
462
578
  };
463
579
  if (!runtime_id || !harness) return interrupted('no runtime recorded on the binding');
580
+ const held = this.state.profiles[profileName]?.bindings?.[keyString];
581
+ const owner=agentRunForSession(this.state.root,[`${harness}:${held?.worker?.session_id??runtime_id}`,`${harness}:${runtime_id}`]);
582
+ let preamble=null;
583
+ if(owner) {
584
+ try {
585
+ const context=process.env.SUPERCODE_CONTEXT;
586
+ const intent=agentIntent(this.state.root,owner.org,owner.agent_id);
587
+ if(!intent)throw new Error('retained session has no agent profile intent');
588
+ try{
589
+ const live=agentDoor('session',[owner.session_ref],{context}).data;
590
+ if(live.agent?.id!==owner.agent_id||live.agent.state!=='active')throw new Error('retained session agent is no longer active or has different ownership');
591
+ const previous=agentDoor('sessions',[owner.agent_id,{limit:200}],{context}).items??[];
592
+ preamble=agentPreamble({identity:live.agent,intended:intent.profile,assignments:intent.assignments},{main:live.main,previous});
593
+ }catch(error){
594
+ // Teams cannot be asked: the session continues as the agent this home recorded, never as another.
595
+ if(!teamsUnavailable(error))throw error;
596
+ preamble=agentPreamble(localAgentRecord(this.state.root,owner.org,owner.agent_id),{main:owner.main===1,previous:localAgentSessions(this.state.root,owner.org,owner.agent_id)});
597
+ }
598
+ }catch(error){return interrupted(`agent identity unavailable: ${error.message}`);}
599
+ }
464
600
  let capable = false;
465
601
  try {
466
602
  capable = Boolean((await this.descriptorFor(harness))?.runtime?.capabilities?.attach_existing_process);
@@ -475,8 +611,8 @@ export class Orchestrator {
475
611
  return interrupted(`attach_existing failed: ${error.message}`);
476
612
  }
477
613
  this.runtimes.set(leaseId(profileName, keyString), runtime);
614
+ if(preamble)(this.runtimeAgentPreambles??=new Map()).set(leaseId(profileName,keyString),preamble);
478
615
  this.watch(profileName, keyString, runtime);
479
- const held = this.state.profiles[profileName]?.bindings?.[keyString];
480
616
  this.enqueue(events.worker({
481
617
  profile: profileName, keyString, runtime_id: runtime.handle.runtime_id,
482
618
  ev: { kind: 'started', runtime_id: runtime.handle.runtime_id, session_id: held?.worker?.session_id || runtime.handle.runtime_id, connection: runtime.connection, harness, reattach_of: runtime_id },
@@ -540,6 +676,14 @@ export class Orchestrator {
540
676
 
541
677
  onWorkerEvent(profileName, keyString, runtime, ev) {
542
678
  const at = this.clock();
679
+ // Child tool frames cannot transfer the parent runtime's ownership to their own native id.
680
+ const nativeId=!ev.payload?.parent_tool_use_id&&(ev.payload?.type==='system'&&ev.payload?.subtype==='init'?ev.payload.session_id:ev.sessionId!==runtime.handle.runtime_id?ev.sessionId:null);
681
+ if(typeof nativeId==='string'&&nativeId) {
682
+ try {
683
+ recordNativeAgentSession(this.state.root,`${runtime.handle.harness}:${runtime.handle.runtime_id}`,`${runtime.handle.harness}:${nativeId}`);
684
+ this.enqueue(events.worker({profile:profileName,keyString,runtime_id:runtime.handle.runtime_id,ev:{kind:'session',session_id:nativeId},at}));
685
+ }catch(error){this.log('warn',`native session ownership: ${error.message}`);}
686
+ }
543
687
  // every event the runtime reports is the worker being alive, including the
544
688
  // tool calls, reasoning and turn starts the reducer does not otherwise read
545
689
  let w = normalizeWorkerEvent(ev) ?? { kind: 'activity' };
@@ -584,7 +728,7 @@ export class Orchestrator {
584
728
  // `cron_incomplete_no_output` for a run that said nothing)
585
729
  const said = (loaded.session.messages ?? []).some((m) => m.role === 'assistant' && (typeof m.content === 'string' ? m.content.trim() : (m.content ?? []).length));
586
730
  const end = b.recurrence && final ? (said ? 'cron_complete' : 'cron_incomplete_no_output') : null;
587
- const got = await mirrorSession(profile.dir, {
731
+ const got = await mirrorSession(this.sessionStateHome(profile,w), {
588
732
  id: w.session_id, sessionKey: hermesSessionKey(profileName, b.key), binding: b, session: loaded.session,
589
733
  locator: locator.storage?.path ?? null, parent: this.continued?.get(keyString) ?? null, end,
590
734
  });
@@ -599,7 +743,7 @@ export class Orchestrator {
599
743
  const cached = this.locators?.get(keyString);
600
744
  if (cached?.session_id === w.session_id) return cached;
601
745
  const { sessions } = await this.client.discover({
602
- harnesses: [w.harness], homes: WORKER_STORE[w.harness](join(profile.dir, w.harness)),
746
+ harnesses: [w.harness], homes: WORKER_STORE[w.harness](w.harness==='hermes'?this.sessionStateHome(profile,w):join(profile.dir, w.harness)),
603
747
  include_child_sessions: true, root_session_id: w.session_id, limit: 8,
604
748
  });
605
749
  const found = sessions.find((d) => d.locator.session_id === w.session_id)?.locator ?? null;
@@ -613,16 +757,21 @@ export class Orchestrator {
613
757
  * held when this home was its), its transcript is handed to the worker's harness and materialized in
614
758
  * the worker's home, the old mirror sealed, and that new session resumed; otherwise `sessionId`.
615
759
  */
616
- async continueFromHermes(profile, keyString, worker, sessionId, cwd) {
760
+ sessionStateHome(profile,worker) {
761
+ const owner=agentRunForSession(this.state.root,[`${worker.harness}:${worker.session_id}`,`${worker.harness}:${worker.runtime_id}`]);
762
+ return owner?materializeAgentHome(this.state.root,owner.org,owner.agent_id,profile):profile.dir;
763
+ }
764
+
765
+ async continueFromHermes(profile, keyString, worker, sessionId, cwd,stateHome=this.sessionStateHome(profile,{...worker,session_id:sessionId})) {
617
766
  const store = WORKER_STORE[worker.harness];
618
767
  if (!store) return sessionId;
619
- const state = await mirrorState(profile.dir, sessionId);
768
+ const state = await mirrorState(stateHome, sessionId);
620
769
  if (!state?.ahead) return sessionId;
621
770
  try {
622
- const hermes = { harness: 'hermes', session_id: sessionId, storage: { kind: 'file', path: join(profile.dir, 'state.db') } };
771
+ const hermes = { harness: 'hermes', session_id: sessionId, storage: { kind: 'file', path: join(stateHome, 'state.db') } };
623
772
  const { artifact } = await this.client.handoffSession({ locator: hermes, target_harness: worker.harness, cwd });
624
773
  const { locator } = await this.client.materializeSession({ artifact, cwd, homes: store(join(profile.dir, worker.harness)) });
625
- await sealMirror(profile.dir, sessionId, locator.session_id);
774
+ await sealMirror(stateHome, sessionId, locator.session_id);
626
775
  (this.locators ??= new Map()).set(keyString, locator);
627
776
  (this.continued ??= new Map()).set(keyString, sessionId);
628
777
  this.log('info', `resume ${keyString}: Hermes carried ${sessionId} on (${state.messages} messages); it goes on as ${worker.harness} ${locator.session_id}`);
@@ -644,15 +793,56 @@ export class Orchestrator {
644
793
  try { await runtime.steer(text); } catch (error) { requeue(error.message); }
645
794
  }
646
795
 
647
- async sendInput({ profile, keyString, text, context_from = null, skills = [], job_id = null }) {
796
+ /** A session's whole locator: the harness's doors load a session by its storage address, which discovery names. */
797
+ async wholeLocator(locator) {
798
+ if (locator.storage) return locator;
799
+ const found = await this.client.request('harness.v1.sessions.discover', { query: locator.session_id, harnesses: [locator.harness], limit: 200 });
800
+ const whole = found.sessions?.find((item) => item.locator?.harness === locator.harness && item.locator?.session_id === locator.session_id)?.locator;
801
+ if (!whole) throw new Error(`${locator.harness}:${locator.session_id} is not in its harness catalogue`);
802
+ return whole;
803
+ }
804
+
805
+ /** G3: a session's usage, recomputed whole from its harness's own records, returns as an event. */
806
+ async refreshUsage({ session, agent }) {
807
+ try {
808
+ const locator = await this.wholeLocator({ harness: session.harness, session_id: session.session_id });
809
+ const reply = await this.client.request('harness.v1.sessions.usage', { locator });
810
+ this.enqueue({ type: 'usage', session: `${session.harness}:${session.session_id}`, agent, usage: reply.usage ?? [], at: this.clock() });
811
+ } catch (error) { this.log('warn', `usage of ${session.harness}:${session.session_id} unread: ${error.message}`); }
812
+ }
813
+
814
+ /** G3: a refused start is told where the profile's home is and logged; nothing is paused or ended. */
815
+ tellBudgetRefusal({ profile, agent, reason }) {
816
+ this.log('warn', `budget: ${reason}`);
817
+ const owner = this.state.profiles[profile];
818
+ const home = owner ? homeChannel(owner) : null;
819
+ if (home) this.enqueue(events.operator({ verb: 'send_message', profile, args: { key: home, text: `Budget: ${reason}. Its next start waits for the window to pass or the budget to change.` }, at: this.clock() }));
820
+ }
821
+
822
+ /** G2: what a transfer's receiver is given of the conversation, from the giving session's own transcript. */
823
+ async transferHistory(history) {
824
+ const tail = history.mode === 'filtered' ? Math.max(1, Math.min(200, Number(history.filter?.last ?? 20))) : 200;
825
+ try {
826
+ const loaded = await this.client.loadWindow(await this.wholeLocator({ harness: history.harness, session_id: history.session_id }), { message_tail: tail });
827
+ const lines = (loaded.session?.messages ?? []).filter((m) => ['user', 'assistant'].includes(m.role) && m.content)
828
+ .map((m) => `${m.role === 'user' ? 'them' : history.from}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`);
829
+ return lines.length ? `The conversation so far (${history.mode === 'full' ? 'whole' : `last ${tail}`}, from ${history.from}'s session):\n${lines.join('\n')}\n\n` : '';
830
+ } catch (error) { this.log('warn', `transfer history unread: ${error.message}`); return ''; }
831
+ }
832
+
833
+ async sendInput({ profile, keyString, text, context_from = null, skills = [], job_id = null, history = null }) {
648
834
  const runtime = this.runtimes.get(leaseId(profile, keyString));
649
835
  if (!runtime) { this.log('warn', `send_input with no runtime for ${keyString}`); return; }
650
836
  const owner = this.state.profiles[profile];
651
837
  const dir = owner?.dir;
652
838
  let input = context_from && dir ? contextFromPrefix(dir, job_id, context_from) + text : text;
839
+ if (history) input = (await this.transferHistory(history)) + input;
653
840
  if (skills?.length && owner) input = await this.skillsPrefix(owner, skills, owner.jobs?.[job_id]?.workdir ?? null, input) ?? input;
841
+ const lid=leaseId(profile,keyString),preamble=this.runtimeAgentPreambles?.get(lid);
842
+ if(preamble)input=`${preamble}\n\n${input}`;
654
843
  try {
655
844
  await runtime.sendInput(input);
845
+ this.runtimeAgentPreambles?.delete(lid);
656
846
  } catch (error) {
657
847
  // A turn the harness is still running (one this loop did not start, or whose end it has not heard yet) is not a
658
848
  // dead session: the line waits for that turn's end, as a refused steer does. Ending the binding on it killed a
package/mcp/bridge.mjs CHANGED
@@ -37,7 +37,8 @@
37
37
  import { SOCKET_ENV } from './server.mjs';
38
38
  import { createServer } from 'node:net';
39
39
  import { rmSync } from 'node:fs';
40
- import { runVerb } from './verbs.mjs';
40
+ import { runVerb, keyFromString } from './verbs.mjs';
41
+ import { surfaceKeyString } from '../ir.mjs';
41
42
  import { applyOperator } from '../operator.mjs';
42
43
 
43
44
  export { socketPathFor } from './server.mjs';
@@ -122,7 +123,17 @@ async function dispatch(orch, call, log) {
122
123
  async function dispatchOperator(orch, { op, args, profile }, log) {
123
124
  const name = typeof profile === 'string' && profile ? profile : 'default';
124
125
  let answer;
125
- try {
126
+ if (op === 'agents.fork') {
127
+ // Operation `fork`: a declared child agent, its profile a copy of the parent's, its session a fork of the parent's.
128
+ try {
129
+ const a = args ?? {};
130
+ for (const field of ['surface', 'parent', 'harness', 'session_id', 'name', 'key']) if (typeof a[field] !== 'string' || !a[field]) throw new Error(`agents.fork needs ${field}`);
131
+ const key = keyFromString(a.surface);
132
+ if (!key) throw new Error(`surface ${JSON.stringify(a.surface)} is not a surface key: write it as platform|kind|chat_id|thread_id|participant_id`);
133
+ const forked = await orch.forkRuntime({ profile: name, keyString: surfaceKeyString(key), parent: a.parent, locator: { harness: a.harness, session_id: a.session_id }, name: a.name, key: a.key, machine: a.machine ?? null });
134
+ answer = { ok: true, result: { ran: `forked ${a.harness}:${a.session_id} as agent ${forked.agent.path} (${forked.agent.id})`, agent: forked.agent, locator: forked.locator } };
135
+ } catch (error) { answer = { ok: false, error: error.message }; }
136
+ } else try {
126
137
  answer = await applyOperator(orch.state, op, args ?? {}, name, orch.clock(), (event) => orch.dispatch(event));
127
138
  } catch (error) {
128
139
  answer = { ok: false, error: error.message };
package/mcp/tools.mjs CHANGED
@@ -42,6 +42,23 @@ export const TOOLS = Object.freeze([
42
42
  required: ['to'],
43
43
  },
44
44
  },
45
+ {
46
+ name: 'conversation_transfer',
47
+ // G2: OpenAI Agents' handoff, AutoGen Swarm's HandoffMessage, the voice front's hand_off.
48
+ description:
49
+ 'Hand this conversation to another agent of this home: your binding ends, the agent takes the conversation '
50
+ + 'and is given its history. Delegating work is a card, not a transfer.',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ to: { type: 'string', description: 'The receiving agent of this home, by name: a profile (`default`, or a profile folder\'s name) or an agent declared in it; never a Claude Code session. A refused name is answered with the home\'s agents.' },
55
+ reason: { type: 'string', description: 'Why, in one line; the receiver is told.' },
56
+ history: { type: 'string', enum: ['full', 'filtered', 'none'], description: 'What of the conversation the receiver is given (default full).' },
57
+ last: { type: 'integer', minimum: 1, maximum: 200, description: 'For filtered: how many recent messages.' },
58
+ },
59
+ required: ['to', 'reason'],
60
+ },
61
+ },
45
62
  ]);
46
63
 
47
64
  /**