@volter/supercode-orchestrator 0.5.206 → 0.5.208

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/connection.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // The kit's connection coordinator. Module manifests and programs belong to their application publisher.
2
- import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { createHash, randomBytes } from 'node:crypto';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -7,7 +7,15 @@ import { spawn } from 'node:child_process';
7
7
  import { fromContext } from '@volter/teams/context';
8
8
  import { supercodeHome } from '@volter/teams/home';
9
9
 
10
+ // RH2's modules, each its own program under --modules-root.
10
11
  export const MODULE_APPS = ['arcs-rooms','orchestration-rh2'];
12
+ // supercode's Workplace service's registrations (supercode ADR 0041 decision 3; runhuman-2 ADR 0055 decision 3): one program,
13
+ // sdk/teams/workplace/main.mjs, hosts them all, each with its own credentials on both sides. Their manifests ship here, so an
14
+ // organization's file selects them by these names and `rh2 apply-org` reads them with --modules-root SERVICE_REGISTRATIONS.
15
+ export const SERVICE_APPS = ['agents','cards','machines'];
16
+ export const SERVICE_REGISTRATIONS = fileURLToPath(new URL('../teams/workplace/registrations/', import.meta.url));
17
+ export const SERVICE_PROGRAM = fileURLToPath(new URL('../teams/workplace/main.mjs', import.meta.url));
18
+ const SERVICE_TOKENS = { agents: ['AGENTS_WORKPLACE_TOKEN','AGENTS_TEAMS_TOKEN'], cards: ['CARDS_WORKPLACE_TOKEN','CARDS_TEAMS_TOKEN'], machines: ['MACHINES_WORKPLACE_TOKEN','MACHINES_TEAMS_TOKEN'] };
11
19
  export function privateJson(path, value) {
12
20
  mkdirSync(dirname(path),{recursive:true,mode:0o700});
13
21
  const pending=`${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
@@ -16,37 +24,30 @@ export function privateJson(path, value) {
16
24
  export async function connectionCall(record, side, method, path, body, token) {
17
25
  const origin=side==='teams'?record.teams.server:record.origin;
18
26
  // Registration, consent, exchange and renewal have receiver-staged random values in their bodies:
19
- // their digest is one operation's key, retained on a retry. A lease refresh is a new operation even
20
- // with the same keys/results. Keep its key in custody until an answer is received.
21
- const lease=side==='teams'&&method==='POST'&&path.endsWith('/issuer-keyring');
22
- let operationKey;
23
- if(lease){
24
- const digest=createHash('sha256').update(JSON.stringify(body)).digest('hex');
25
- if(record.issuerKeyOperation?.digest!==digest)record.issuerKeyOperation={digest,key:`connect-${randomBytes(24).toString('hex')}`};
26
- operationKey=record.issuerKeyOperation.key;
27
- privateJson(connectionPath(record.root,record.id),record);
28
- }
27
+ // their digest is one operation's key, retained on a retry.
29
28
  const began=Date.now();
30
- const response=await fetch(`${origin}${path}`,{method,redirect:'manual',signal:AbortSignal.timeout(path.endsWith('issuer-checks')?30000:9000),
29
+ const response=await fetch(`${origin}${path}`,{method,redirect:'manual',signal:AbortSignal.timeout(9000),
31
30
  headers:{accept:'application/json',...(token?{authorization:`Bearer ${token}`} : {}),
32
31
  ...(side==='rh2'?{origin:record.origin,'x-rh2-organization':record.organization}:{}),
33
32
  ...(body===undefined?{}:{'content-type':'application/json'}),
34
- ...(['POST','PATCH','DELETE'].includes(method)?{'idempotency-key':operationKey??`connect-${createHash('sha256').update(`${method}\n${path}\n${JSON.stringify(body)}`).digest('hex')}`}:{})},
33
+ ...(['POST','PATCH','DELETE'].includes(method)?{'idempotency-key':`connect-${createHash('sha256').update(`${method}\n${path}\n${JSON.stringify(body)}`).digest('hex')}`}:{})},
35
34
  ...(body===undefined?{}:{body:JSON.stringify(body)})});
36
35
  const text=await response.text();
37
36
  record.calls??=[];record.calls.push({side,method,path,status:response.status,ms:Date.now()-began,bytes:Buffer.byteLength(text)});record.calls=record.calls.slice(-200);
38
- let payload;try{payload=JSON.parse(text);}catch{throw new Error(`${side} ${method} ${path}: invalid JSON (${response.status})`);}
37
+ let payload;try{payload=text===''&&response.status===204?{}:JSON.parse(text);}catch{throw new Error(`${side} ${method} ${path}: invalid JSON (${response.status})`);}
39
38
  if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status});
40
- if(lease){delete record.issuerKeyOperation;privateJson(connectionPath(record.root,record.id),record);}
41
39
  return payload;
42
40
  }
43
41
  export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
44
42
  export function generateModuleConfig(root, record, grant) {
43
+ if(SERVICE_APPS.includes(grant.name))return generateServiceConfig(root,record,grant);
45
44
  const folder=dirname(connectionPath(root,record.id)), dir=join(folder,grant.name);
46
45
  mkdirSync(dir,{recursive:true,mode:0o700});
47
46
  const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
48
47
  writeFileSync(custody,`RH2_TOKEN=${grant.rh2.accessToken}\n${grant.teams?`TEAMS_TOKEN=${grant.teams.credential.token}\n`:''}`,{mode:0o600});chmodSync(custody,0o600);
49
- const config={rh2:{baseUrl:record.origin,issuer:record.issuer,organization:record.organization,principalId:grant.rh2.principalId,tokenEnv:'RH2_TOKEN'},
48
+ // The arcs module seats card agents by resolving them in the registration that provides them: the connection's agents.
49
+ const agents=record.grants.find(g=>g.name==='agents'&&g.clientId), cards=record.grants.find(g=>g.name==='cards'&&g.rh2);
50
+ const config={rh2:{baseUrl:record.origin,issuer:record.issuer,organization:record.organization,principalId:grant.rh2.principalId,tokenEnv:'RH2_TOKEN',...(agents?{agentsRegistration:agents.clientId}:{}),...(cards?{cardsPrincipalId:cards.rh2.principalId}:{})},
50
51
  teams:{server:record.teams.server,team:record.teams.team,source:record.source,tokenEnv:'TEAMS_TOKEN'},
51
52
  envFile:custody,stateFile:join(dir,'state.json'),defaultFloor:record.floor,
52
53
  source:{key:`connection-${record.scope}`,namespace:`cx${record.scope}`,name:`${record.organization} / ${record.source}`,home:root,...(record.teamsContext?{teamsContext:record.teamsContext}:{}),...(process.env.SUPERCODE_BIN?{program:process.env.SUPERCODE_BIN}:{}),harness:record.harness??'hermes'},
@@ -56,6 +57,30 @@ export function generateModuleConfig(root, record, grant) {
56
57
  origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
57
58
  return configPath;
58
59
  }
60
+ /** The Workplace service's config and custody, written from the connection alone (no hand-written config): every installed
61
+ * service registration's credentials, ids and state, and the home's registry entry for `grant`. One config serves all. */
62
+ export function generateServiceConfig(root, record, grant) {
63
+ const folder=dirname(connectionPath(root,record.id)), dir=join(folder,'service');
64
+ mkdirSync(dir,{recursive:true,mode:0o700});
65
+ const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
66
+ const held=Object.fromEntries(record.grants.filter(g=>SERVICE_APPS.includes(g.name)&&g.rh2&&g.teams).map(g=>[g.name,g]));
67
+ writeFileSync(custody,Object.entries(held).map(([name,g])=>`${SERVICE_TOKENS[name][0]}=${g.rh2.accessToken}\n${SERVICE_TOKENS[name][1]}=${g.teams.credential.token}\n`).join(''),{mode:0o600});chmodSync(custody,0o600);
68
+ const agents=held.agents;
69
+ if(agents){
70
+ const section=name=>({registration:held[name].clientId,principal:held[name].rh2.principalId,tokenEnv:SERVICE_TOKENS[name][0],teamsTokenEnv:SERVICE_TOKENS[name][1],stateFile:join(dir,`${name}.state.json`)});
71
+ const config={
72
+ teams:{server:record.teams.server,team:record.teams.team,installation:agents.teams.installation.id,source:record.source,tokenEnv:SERVICE_TOKENS.agents[1]},
73
+ rh2:{baseUrl:record.origin,organization:record.organization,registration:agents.clientId,...(record.issuer!==record.origin?{issuer:record.issuer}:{}),tokenEnv:SERVICE_TOKENS.agents[0],principal:agents.rh2.principalId},
74
+ source:{namespace:`cx${record.scope}`},envFile:custody,stateFile:join(dir,'agents.state.json'),
75
+ ...(record.webhook?{webhook:record.webhook}:{}),
76
+ ...(held.cards?{cards:section('cards')}:{}),...(held.machines?{machines:section('machines')}:{}),
77
+ };
78
+ privateJson(configPath,config);
79
+ }
80
+ privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),{app:grant.name,connection:record.id,configPath,program:SERVICE_PROGRAM,
81
+ origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
82
+ return configPath;
83
+ }
59
84
  export function startConnectionRunner(root,id) {
60
85
  const recordPath=connectionPath(root,id);
61
86
  if(!existsSync(recordPath)||!connectionCanRun(JSON.parse(readFileSync(recordPath,'utf8'))))return null;
@@ -63,12 +88,18 @@ export function startConnectionRunner(root,id) {
63
88
  const child=spawn(process.execPath,[fileURLToPath(new URL('./bin/connection-runner.mjs',import.meta.url)),'--root',root,'--connection',id],{detached:true,stdio:['ignore',fd,fd],env:process.env});
64
89
  closeSync(fd);child.unref();return child.pid;
65
90
  }
66
- export function connectionCanRun(record){return record.preparing===false&&record.paused===false&&typeof record.connectedAt==='string';}
91
+ export function connectionCanRun(record){return record.preparing===false&&record.paused===false&&typeof record.connectedAt==='string'&&!record.disconnectedAt;}
67
92
  export async function connectWorkplace(flags) {
68
- if(!flags.root||!flags['ticket-file']||!flags['modules-root']||!flags.source)throw new Error('connect needs --root, --ticket-file, --modules-root and --source (one board event source)');
93
+ if(!flags.root||!flags['ticket-file']||!flags.source)throw new Error('connect needs --root, --ticket-file and --source (one board event source)');
69
94
  const root=resolve(flags.root), ticket=JSON.parse(readFileSync(flags['ticket-file'],'utf8'));
70
95
  if(!/^[0-9a-f]{24}$/.test(ticket.scope??''))throw new Error('connection ticket needs a source namespace; apply to a new private output path');
71
- if(ticket.version!==1||!Array.isArray(ticket.grants)||!ticket.grants.length||ticket.grants.some(g=>!MODULE_APPS.includes(g.name))||new Set(ticket.grants.map(g=>g.name)).size!==ticket.grants.length)throw new Error('unsupported or duplicate connection modules');
96
+ if(ticket.version!==1||!Array.isArray(ticket.grants)||!ticket.grants.length||ticket.grants.some(g=>!MODULE_APPS.includes(g.name)&&!SERVICE_APPS.includes(g.name))||new Set(ticket.grants.map(g=>g.name)).size!==ticket.grants.length)throw new Error(`unsupported or duplicate connection apps (connect takes ${[...MODULE_APPS,...SERVICE_APPS].join(', ')})`);
97
+ const names=ticket.grants.map(g=>g.name);
98
+ // The service is the agents registration's host: its other registrations run only inside it.
99
+ if(names.some(name=>name!=='agents'&&SERVICE_APPS.includes(name))&&!names.includes('agents'))throw new Error('cards and machines run in the Workplace service, which the agents registration hosts: select agents too');
100
+ if(names.some(name=>MODULE_APPS.includes(name))&&!flags['modules-root'])throw new Error(`--modules-root names where ${names.filter(name=>MODULE_APPS.includes(name)).join(' and ')} are`);
101
+ if(Boolean(flags['webhook-url'])!==Boolean(flags['webhook-listen']))throw new Error('--webhook-url and --webhook-listen go together: where the Workplace sends the agents registration\'s events, and the loopback address the service listens on');
102
+ const moduleRoot=flags['modules-root']?resolve(flags['modules-root']):null;
72
103
  // An explicit local credential input is useful for an owned stack; a normal connect uses its existing Teams context.
73
104
  let teams,owner;
74
105
  if(flags['teams-token-file']){
@@ -86,9 +117,13 @@ export async function connectWorkplace(flags) {
86
117
  const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
87
118
  if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('connection ticket expired; apply the organization file again');
88
119
  const record=prior??{version:1,id,scope:ticket.scope,teamsContext:flags.context??process.env.SUPERCODE_CONTEXT,origin:ticket.origin,issuer:ticket.issuer,organization:ticket.organization,teams,source:flags.source,
89
- selection:ticket.grants.map(g=>g.name),moduleRoot:resolve(flags['modules-root']),floor:flags.floor??ticket.floor??'Work',alertRole:flags['alert-role']??'admin',grants:[]};
120
+ selection:ticket.grants.map(g=>g.name),moduleRoot,...(flags['webhook-url']?{webhook:{url:flags['webhook-url'],listen:flags['webhook-listen']}}:{}),floor:flags.floor??ticket.floor??'Work',alertRole:flags['alert-role']??'admin',grants:[]};
90
121
  record.root=root;
91
- if(prior&&(record.source!==flags.source||record.moduleRoot!==resolve(flags['modules-root'])||record.selection.join(',')!==ticket.grants.map(g=>g.name).join(',')))throw new Error('the existing connection has different module/source settings; use a new connection ticket before replacing it');
122
+ if(prior?.disconnectedAt)throw new Error('this connection was disconnected; apply the organization file again for a new ticket');
123
+ // The service's event address is part of the connection: a different one is a new connection, never silently dropped.
124
+ const webhook=flags['webhook-url']?{url:flags['webhook-url'],listen:flags['webhook-listen']}:undefined;
125
+ if(prior&&webhook&&JSON.stringify(prior.webhook??null)!==JSON.stringify(webhook))throw new Error('the existing connection has a different --webhook-url/--webhook-listen; use a new connection ticket before replacing it');
126
+ if(prior&&(record.source!==flags.source||(record.moduleRoot??null)!==moduleRoot||record.selection.join(',')!==ticket.grants.map(g=>g.name).join(',')))throw new Error('the existing connection has different module/source settings; use a new connection ticket before replacing it');
92
127
  const lock=join(dirname(path),'runner.lock');
93
128
  if(prior&&existsSync(lock)) {
94
129
  const running=JSON.parse(readFileSync(lock,'utf8'));
@@ -106,7 +141,8 @@ export async function connectWorkplace(flags) {
106
141
  for(const selected of ticket.grants){
107
142
  let grant=record.grants.find(g=>g.name===selected.name);
108
143
  if(grant && JSON.stringify([grant.manifest,grant.teamsManifest,grant.rooms])!==JSON.stringify([selected.manifest,selected.teamsManifest,selected.rooms]))throw new Error('existing connection consent differs from the ticket');
109
- if(!grant){grant={name:selected.name,rooms:selected.rooms,manifest:selected.manifest,teamsManifest:selected.teamsManifest};record.grants.push(grant);privateJson(path,record);}
144
+ if(!grant){grant={name:selected.name,clientId:selected.clientId,rooms:selected.rooms,manifest:selected.manifest,teamsManifest:selected.teamsManifest};record.grants.push(grant);privateJson(path,record);}
145
+ if(SERVICE_APPS.includes(grant.name)&&!selected.teamsManifest)throw new Error(`${grant.name}: its Teams manifest is missing from the ticket (apply with --modules-root naming ${SERVICE_REGISTRATIONS})`);
110
146
  if(!grant.rh2){
111
147
  const recovering=typeof grant.redemption==='string';
112
148
  grant.redemption??=randomBytes(32).toString('base64url');privateJson(path,record);
@@ -136,13 +172,13 @@ export async function connectWorkplace(flags) {
136
172
  grant.teams=await connectionCall(record,'teams','POST','/api/v1/apps/token',{app_id:grant.registration.id,app_secret:grant.registration.secret,code:grant.consent.code,redirect_url:selected.teamsManifest.redirect_urls[0],credential_token:grant.stagedCredential});privateJson(path,record);
137
173
  delete grant.consent;delete grant.registration.secret;privateJson(path,record);
138
174
  }
139
- generateModuleConfig(root,record,grant);
175
+ if(!SERVICE_APPS.includes(grant.name))generateModuleConfig(root,record,grant);
140
176
  }
141
- if(!record.issuerRegistration){record.issuerStagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);record.issuerRegistration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{name:`Workplace issuer (${id.slice(0,8)})`,description:'Installation-scoped outbound delegated trust',actions:['delegation.sessions','delegation.surfaces','delegation.notices','delegation.machines'],event_kinds:[],redirect_urls:['http://127.0.0.1/connected'],issuer:record.issuer,outbound_issuer:true,secret:record.issuerStagedSecret},owner)).data;privateJson(path,record);}
177
+ // The service's config names every one of its registrations, so it is written once all of them hold credentials.
178
+ for(const grant of record.grants.filter(g=>SERVICE_APPS.includes(g.name)))generateServiceConfig(root,record,grant);
179
+ if(!record.issuerRegistration){record.issuerStagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);record.issuerRegistration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{name:`Workplace issuer (${id.slice(0,8)})`,description:'The Workplace as a delegated issuer, verified against its JWKS',actions:['delegation.sessions','delegation.surfaces','delegation.notices','delegation.machines'],event_kinds:[],redirect_urls:['http://127.0.0.1/connected'],issuer:record.issuer,secret:record.issuerStagedSecret},owner)).data;privateJson(path,record);}
142
180
  if(!record.issuerConsent&&!record.issuerCredential){record.issuerStagedCode??=`sci_${randomBytes(32).toString('base64url')}`;privateJson(path,record);record.issuerConsent=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps/${record.issuerRegistration.id}/installations`,{redirect_url:'http://127.0.0.1/connected',state:id,code:record.issuerStagedCode},owner)).data;privateJson(path,record);}
143
181
  if(!record.issuerCredential){record.issuerStagedCredential??=newInstallationToken();privateJson(path,record);record.issuerCredential=await connectionCall(record,'teams','POST','/api/v1/apps/token',{app_id:record.issuerRegistration.id,app_secret:record.issuerStagedSecret,code:record.issuerStagedCode,redirect_url:'http://127.0.0.1/connected',credential_token:record.issuerStagedCredential});privateJson(path,record);delete record.issuerConsent;delete record.issuerRegistration.secret;privateJson(path,record);}
144
- const keys=(await connectionCall(record,'rh2','GET','/.well-known/jwks.json')).keys;
145
- await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/app-installations/${record.issuerCredential.installation.id}/issuer-keyring`,{keys,results:[]},record.issuerCredential.credential.token);
146
182
  privateJson(join(dirname(path),'standing.json'),[]);
147
183
  record.connectedAt??=new Date().toISOString();
148
184
  record.preparing=false;record.paused=Boolean(flags['no-start']);privateJson(path,record);
@@ -150,4 +186,37 @@ export async function connectWorkplace(flags) {
150
186
  return {connection:id,root,modules:record.grants.map(g=>g.name),runner:pid,calls:record.calls.map(({side,method,path,status,ms,bytes})=>({side,method,path,status,ms,bytes}))};
151
187
  }
152
188
 
189
+ /** Undo a connection with its own credentials, no admin needed (runhuman-2 ADR 0055 decision 3): its runner stops, every
190
+ * installation it holds leaves both sides by itself (each Workplace installation through its own uninstall door, each
191
+ * Teams installation and the issuer's by deleting itself), and the home's registry entries go. The registrations stay, as
192
+ * consented apps with no installation; an admin deregisters them. The custody record stays, marked disconnected, for
193
+ * audit; it can never run again. */
194
+ export async function disconnectWorkplace(flags) {
195
+ if(!flags.root||!flags.connection)throw new Error('disconnect needs --root and --connection (the id connect printed)');
196
+ const root=resolve(flags.root), path=connectionPath(root,flags.connection);
197
+ if(!existsSync(path))throw new Error(`no connection ${flags.connection} in ${root}`);
198
+ const record=JSON.parse(readFileSync(path,'utf8'));record.root=root;
199
+ record.paused=true;privateJson(path,record);
200
+ const lock=join(dirname(path),'runner.lock');
201
+ if(existsSync(lock)){
202
+ const running=JSON.parse(readFileSync(lock,'utf8'));
203
+ try{process.kill(running.pid,'SIGTERM');}catch(error){if(error.code!=='ESRCH')throw error;}
204
+ for(let waited=0;waited<20000;waited+=250){try{process.kill(running.pid,0);}catch{break;}await new Promise(done=>setTimeout(done,250));}
205
+ }
206
+ const team=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`, left=[];
207
+ const leave=async(name,side,method,door,token)=>{
208
+ try{await connectionCall(record,side,method,door,side==='rh2'?{}:undefined,token);left.push({name,side,left:true});}
209
+ catch(error){if(error.status===404||error.code==='installation-unknown'){left.push({name,side,left:true,already:true});}else left.push({name,side,left:false,code:error.code??error.status??error.message});}
210
+ privateJson(path,record);
211
+ };
212
+ for(const grant of record.grants){
213
+ if(grant.teams?.installation?.id)await leave(grant.name,'teams','DELETE',`${team}/app-installations/${encodeURIComponent(grant.teams.installation.id)}`,grant.teams.credential.token);
214
+ if(grant.rh2?.installationId)await leave(grant.name,'rh2','POST',`/api/v3/automation/installations/${encodeURIComponent(grant.rh2.installationId)}/uninstall`,grant.rh2.accessToken);
215
+ const entry=join(root,'apps',`${grant.name}.${record.id}.json`);if(existsSync(entry))unlinkSync(entry);
216
+ }
217
+ if(record.issuerCredential?.installation?.id)await leave('issuer','teams','DELETE',`${team}/app-installations/${encodeURIComponent(record.issuerCredential.installation.id)}`,record.issuerCredential.token);
218
+ record.disconnectedAt=new Date().toISOString();record.left=left;privateJson(path,record);
219
+ return {connection:record.id,root,left,registrations:[...record.grants.map(g=>g.registration?.id).filter(Boolean),record.issuerRegistration?.id].filter(Boolean)};
220
+ }
221
+
153
222
  export function newInstallationToken(){return `sct_cred_${randomBytes(16).toString('hex')}.${randomBytes(32).toString('base64url')}`;}
package/index.d.ts CHANGED
@@ -131,7 +131,7 @@ export function agentScope(context?: string): {server: string; org: string; prin
131
131
  /** The identity a run starts as without asking Teams; `identity.synced` is false until Teams has its declaration, made later under the same id. */
132
132
  /** A local agent: its daemon-minted id, retained intent and assignments. */
133
133
  export type LocalAgent = {identity: AgentIdentity; intended: ReturnType<typeof resolveAgentProfile>; assignment: string; assignments: Array<Record<string, unknown>>; org: string};
134
- export function localCardAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; profile: string; card: string; role: string; sponsor?: string | null; machine?: string | null; starterSession?: string | null}): LocalAgent & {synced: false};
134
+ export function localCardAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; profile: string; card: string; role: string; handle?: string | null; readableId?: string | null; machine?: string | null; starterSession?: string | null}): LocalAgent & {synced: false};
135
135
  export function localProfileAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; profile: string; machine?: string | null; agent?: string | null; declarations?: Record<string, unknown>}): LocalAgent & {synced: false};
136
136
  export function localForkAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; parent: string; session: string; name: string; key: string}): LocalAgent;
137
137
  /** Hand this home's agent declarations and ends to this machine's daemon, which declares and ends them in Teams. Answers what could not be handed on. */
@@ -349,6 +349,10 @@ export class AgentMailboxBridge {
349
349
  }
350
350
 
351
351
  export class Orchestrator {
352
+ /** Private local preparation only: no hosted runtime or provider process. */
353
+ prepareMainPane(options: {directory: string; name: string; resume?: import('@volter/supercode-harness-sdk').SessionLocator | null}): Promise<{
354
+ ran: string; program: 'codex'; profile: string; cwd: string; arguments: string[]; env: Record<string,string>;
355
+ }>;
352
356
  constructor(o: {
353
357
  state: OrchestratorState;
354
358
  vault?: Vault;
@@ -485,7 +489,7 @@ export interface OrchestrationImported {
485
489
 
486
490
  /** Our folder, strict, through `harness.v1.orchestration.load`; the vault is read out of the home's own `.env`. */
487
491
  export function load(root: Path): Promise<{ state: OrchestratorState; vault: Vault }>;
488
- /** Our folder, canonical and byte-stable, through `harness.v1.orchestration.save`. Sends no secret. */
492
+ /** Runtime persistence through `harness.v1.orchestration.save`. Leaves workflow.yaml untouched; sends no secret. */
489
493
  export function save(state: OrchestratorState, root?: Path): Promise<void>;
490
494
  /** Another harness's home as a state value; `vault_keys` names the `.env` keys it would need. */
491
495
  export function compile(from: 'hermes' | 'openclaw', home: Path): Promise<{ state: OrchestratorState; vault_keys: string[] }>;
package/loop.mjs CHANGED
@@ -4,13 +4,14 @@
4
4
  // This is the only module that performs I/O for the loop. It holds the single
5
5
  // state value, the vault, the SDK client, the adapters, and an injected clock.
6
6
 
7
- import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
7
+ import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
8
8
  import { basename, join, dirname } from 'node:path';
9
9
  import { step, leaseId } from './reducer.mjs';
10
10
  import * as events from './events.mjs';
11
- import { read, save, retainWorld, releaseWorld } from './orchestration.mjs';
11
+ import { read, save, retainWorld, releaseWorld, refreshVault } from './orchestration.mjs';
12
12
  import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, workerCwd, declareWorkerFolder, declareWorkerHome, approvalPolicy, sandboxArguments, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
13
- import { workerRoute } from './worker-route.mjs';
13
+ import { workerRoute, codexPaneRoute } from './worker-route.mjs';
14
+ import { projectCodexPaneResume } from './codex-pane-resume.mjs';
14
15
  import { hermesAccess, hermesApproval, untrustedServerVerdict, hermesToolEnv, memorySnapshot, mirrorSession, mirrorState, recordIncident, sealMirror } from './hermes-cli.mjs';
15
16
  import { hermesSessionKey, homeChannel } from './ir.mjs';
16
17
  import { surfaceKeyString } from './ir.mjs';
@@ -536,6 +537,50 @@ export class Orchestrator {
536
537
  return runtime.handle;
537
538
  }
538
539
 
540
+ /** A private local preparation door for an explicit headful mailbox main.
541
+ * It returns only launch metadata and the configured forward's stand-in key;
542
+ * it creates no hosted conversation, controller lease, process or first turn. */
543
+ async prepareMainPane({ directory, name, resume = null }) {
544
+ if (typeof directory !== 'string' || typeof name !== 'string' || !/^[A-Za-z0-9._-]+$/.test(name))
545
+ throw new Error('a headful main needs its exact profile directory and agent name');
546
+ const folder = realpathSync(directory);
547
+ const profiles = Object.values(this.state.profiles).filter(profile => {
548
+ try { return realpathSync(profile.dir) === folder; } catch { return false; }
549
+ });
550
+ if (!profiles.length) throw Object.assign(new Error('this home has no profile at that directory'), { code: 'not_this_home' });
551
+ if (profiles.length !== 1) throw new Error('the directory names more than one profile; no main was prepared');
552
+ const profile = profiles[0], worker = profile.worker;
553
+ if (worker?.harness !== 'codex') throw new Error('the selected main profile does not run Codex');
554
+ refreshVault(this.state, this.vault);
555
+ const route = codexPaneRoute(profile, worker, this.vault);
556
+ if (resume !== null) {
557
+ if (resume.harness !== 'codex' || typeof resume.session_id !== 'string' || resume.storage?.kind !== 'file')
558
+ throw new Error('a main resume needs its exact Codex transcript locator');
559
+ projectCodexPaneResume(route.env.CODEX_HOME, resume.storage.path, resume.session_id);
560
+ }
561
+ const context = process.env.SUPERCODE_CONTEXT, scope = agentScope(context);
562
+ const agent = await localProfileAgent({ root: this.state.root, org: scope.org, server: scope.server,
563
+ profiles: this.state.profiles, profile: profile.name, agent: name, declarations: this.state.agents });
564
+ const env = { ...route.env, ...agentEnvironment(agent, { root: this.state.root,
565
+ context: localReaderContext(scope.org, agent.identity.id) ?? undefined, main: true }) };
566
+ // The headful main uses the same profile-owned MCP door and identity
567
+ // preamble. The returned plan carries no configured server credentials.
568
+ mountOrchestratorMcp({ root: this.state.root, profile, harness: 'codex',
569
+ socketPath: this.bridge?.path ?? socketPathFor(this.state.root), vault: this.vault,
570
+ agentId: agent.identity.id });
571
+ const instructions = agentPreamble(agent, { main: true,
572
+ previous: localAgentSessions(this.state.root, scope.org, agent.identity.id) });
573
+ const arguments_ = [...route.args, ...INSTRUCTION_ARGUMENTS.codex(instructions)];
574
+ if (resume === null) {
575
+ arguments_.push(...modelArguments({ ...worker, model: route.model }), '--ask-for-approval', approvalPolicy(profile, 'codex'));
576
+ arguments_.push(...sandboxArguments(profile, 'codex'));
577
+ if (worker.reasoning_effort) arguments_.push('-c', `model_reasoning_effort=${JSON.stringify(worker.reasoning_effort)}`);
578
+ }
579
+ declareWorkerHome({ harness: 'codex', env, cwd: folder, log: (level, text) => this.log(level, text) });
580
+ return { ran: `prepared a headful main for profile ${profile.name}`, program: 'codex',
581
+ profile: profile.name, cwd: folder, arguments: arguments_, env };
582
+ }
583
+
539
584
  /**
540
585
  * A new concurrent session of this profile's agent, asked for on its behalf (operator `sessions.launch`): the agent is
541
586
  * the one this home holds for the profile, and must be the one asked for. The session is composed by startRuntime
@@ -570,6 +615,8 @@ export class Orchestrator {
570
615
  const native=prior.session_ref.slice(prior.session_ref.indexOf(':')+1);
571
616
  const page=await this.client.discover({harnesses:[locator.harness],query:native,limit:200});
572
617
  target=page.sessions.find(item=>item.locator.harness===locator.harness&&item.locator.session_id===native)?.locator;
618
+ // a source still being read is no proof the retained session is gone: no replacement is made either way
619
+ if(!target&&page.reconciling?.length)throw new Error(`retained fork session cannot be confirmed yet: the session index is still reading ${page.reconciling.join(', ')}; no replacement was created`);
573
620
  if(!target)throw new Error('retained fork session is not available; no replacement was created');
574
621
  }else {
575
622
  const loaded=await this.client.loadWindow(locator,{message_limit:1});
@@ -814,6 +861,7 @@ export class Orchestrator {
814
861
  if (locator.storage) return locator;
815
862
  const found = await this.client.request('harness.v1.sessions.discover', { query: locator.session_id, harnesses: [locator.harness], limit: 200 });
816
863
  const whole = found.sessions?.find((item) => item.locator?.harness === locator.harness && item.locator?.session_id === locator.session_id)?.locator;
864
+ if (!whole && found.reconciling?.length) throw new Error(`${locator.harness}:${locator.session_id} cannot be found yet: the session index is still reading ${found.reconciling.join(', ')}`);
817
865
  if (!whole) throw new Error(`${locator.harness}:${locator.session_id} is not in its harness catalogue`);
818
866
  return whole;
819
867
  }
package/mcp/bridge.mjs CHANGED
@@ -123,7 +123,10 @@ async function dispatch(orch, call, log) {
123
123
  async function dispatchOperator(orch, { op, args, profile }, log) {
124
124
  const name = typeof profile === 'string' && profile ? profile : 'default';
125
125
  let answer;
126
- if (op === 'agents.fork') {
126
+ if (op === 'agents.main_plan') {
127
+ try { answer = { ok: true, result: await orch.prepareMainPane(args ?? {}) }; }
128
+ catch (error) { answer = { ok: false, error: error.message, ...(error.code ? { code: error.code } : {}) }; }
129
+ } else if (op === 'agents.fork') {
127
130
  // Operation `fork`: a declared child agent, its profile a copy of the parent's, its session a fork of the parent's.
128
131
  try {
129
132
  const a = args ?? {};
@@ -46,7 +46,11 @@
46
46
  },
47
47
  "include_archived": {
48
48
  "type": "boolean",
49
- "description": "Include archived tasks. Defaults to false."
49
+ "description": "Include cancelled and archived cards and their tasks. Defaults to false."
50
+ },
51
+ "include_subtasks": {
52
+ "type": "boolean",
53
+ "description": "Include subtasks as separate rows. Defaults to false; use kanban_show to read a card's tasks inside it."
50
54
  },
51
55
  "limit": {
52
56
  "type": "integer",
package/ontology.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // GENERATED from `supercode ontology schema` — do not edit; run node sdk/orchestrator/scripts/generate-types.mjs
2
2
  //
3
- // The Rust records in `crates/interchange/src/world/` and
4
- // `crates/interchange/src/ontology/` are the source of truth (`docs/ONTOLOGY.md` §2).
3
+ // The committed `sdk/interchange/ontology-schemas.json` contracts own these types.
4
+ // Native serde records remain only with the capabilities still using them.
5
5
 
6
6
  /** The access record, as Hermes's stores hold it. */
7
7
  export interface Access {
@@ -45,8 +45,6 @@ export interface AgentDecl {
45
45
  label?: string | null;
46
46
  /** The agent it is declared under, if not the home's root. */
47
47
  parent?: string | null;
48
- /** A person set on purpose as its sponsor; absent, the sponsor is inherited. */
49
- sponsor?: string | null;
50
48
  /** What a source format said about it that the IR has no field for, verbatim. */
51
49
  residue?: Record<string, unknown>;
52
50
  }
@@ -115,7 +113,7 @@ export interface Binding {
115
113
 
116
114
  /**
117
115
  * A per-action gate on a profile's agents: a start or resume over it is refused, with the reason,
118
- * and the refusal is told to the manager and the sponsor. It never pauses or ends anything itself.
116
+ * and the refusal is told to the manager. It never pauses or ends anything itself.
119
117
  */
120
118
  export interface Budget {
121
119
  /** The window. */
@@ -390,19 +388,7 @@ export type FireStatus =
390
388
  | 'unknown';
391
389
 
392
390
  /** Observations a role can subscribe to, separate from a card's transition events. */
393
- export type FleetEventKind =
394
- /** A native session process first observed running. */
395
- | 'session_started'
396
- /** A known native session observed running in a new process incarnation. */
397
- | 'session_resumed'
398
- /** A native source reports a session process ended; unavailable sources are not ends. */
399
- | 'session_ended'
400
- /** More than one observed live process incarnation names the same native session. */
401
- | 'duplicate_session_process'
402
- /** A card's native session reports that it needs its user's input. */
403
- | 'card_waiting_on_user'
404
- /** A machine's health source reports a critical alarm. */
405
- | 'machine_health_crit';
391
+ export type FleetEventKind = 'session_started' | 'session_resumed' | 'session_ended' | 'duplicate_session_process' | 'card_waiting_on_user' | 'machine_health_crit';
406
392
 
407
393
  /** A conversation moved (or moving) to another surface — Hermes `handoff_*`. */
408
394
  export interface Handoff {
@@ -836,7 +822,7 @@ export type SecretRef =
836
822
  /** A key in the home's own `.env` file. */
837
823
  | { dotenv: string };
838
824
 
839
- /** A session's usage, kept on the session and summed upward (session → agent → sponsor → Org). */
825
+ /** A session's usage, kept on the session and summed upward (session → agent → Org). */
840
826
  export interface SessionUsage {
841
827
  /** The agent the session belongs to, when it has one. */
842
828
  agent?: string | null;
@@ -1134,11 +1120,6 @@ export interface Workflow {
1134
1120
  params?: Record<string, unknown>;
1135
1121
  /** Who acts on a card, each an expression naming a profile (`implementer: card.assignee`) or a person. */
1136
1122
  roles?: Record<string, Expr>;
1137
- /**
1138
- * Opt-in fleet observations delivered as mail to each role's declared addresses. These are not card
1139
- * transitions and grant no observation or machine authority. The manager is an implicit workflow role.
1140
- */
1141
- listeners?: Record<string, RoleFleetListener>;
1142
1123
  /** Where a new card starts: the first transition whose guard holds. */
1143
1124
  initial: Transition[];
1144
1125
  /** Every status, by name. */
@@ -1152,4 +1133,6 @@ export interface Workflow {
1152
1133
  * are), the event kinds it hears and the template each is told in. A kind not named is not sent.
1153
1134
  */
1154
1135
  notify?: Record<string, Record<string, string>>;
1136
+ /** Role fleet listeners; a declaration grants no machine or mailbox authority. */
1137
+ listeners?: Record<string, RoleFleetListener>;
1155
1138
  }
package/orchestration.mjs CHANGED
@@ -150,7 +150,7 @@ export async function load(root) {
150
150
  const { orchestration, vault_keys } = await through((d) => d.orchestrationLoad({ root }));
151
151
  let vault = readVault(orchestration);
152
152
  if (vault_keys.some((k) => !vault.has(k))) {
153
- await through((d) => d.orchestrationSave({ root, orchestration }));
153
+ await through((d) => d.orchestrationSave({ root, orchestration, preserve_workflow: true }));
154
154
  vault = readVault(orchestration);
155
155
  const missing = vault_keys.filter((k) => !vault.has(k));
156
156
  if (missing.length) throw new Error(`${root}: ${missing.join(', ')} referenced but not in .env after normalization`);
@@ -164,12 +164,15 @@ export async function read(root) {
164
164
  }
165
165
 
166
166
  /**
167
- * `save(state, root)` — our folder, canonical and byte-stable: an artifact whose record has not changed keeps its bytes.
167
+ * `save(state, root)` — persist runtime state, preserving the home's workflow.yaml.
168
+ * Board policy belongs to the live home, not the runtime's earlier snapshot. Full
169
+ * home creation/replacement uses orchestrationSave or importFrom explicitly.
170
+ * Other artifacts are canonical and byte-stable when their record has not changed.
168
171
  * Answers the entries the door could not read (`rejected`, each `{ collection, key, reason, kept_stored }`): each was
169
172
  * written as the home last held it, or left out when it held none, and none refuses the rest of the save.
170
173
  */
171
174
  export async function save(state, root = state.root) {
172
- const saved = await through((d) => d.orchestrationSave({ root, orchestration: persistedView(state) }));
175
+ const saved = await through((d) => d.orchestrationSave({ root, orchestration: persistedView(state), preserve_workflow: true }));
173
176
  return { rejected: Array.isArray(saved?.rejected) ? saved.rejected : [] };
174
177
  }
175
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.206",
3
+ "version": "0.5.208",
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": {
@@ -32,10 +32,10 @@
32
32
  "check-types": "node scripts/generate-types.mjs --check"
33
33
  },
34
34
  "dependencies": {
35
- "@volter/supercode-teams": "^0.3.343",
36
- "@volter/supercode-terminal": "^0.3.9",
37
- "@volter/supercode-harness-sdk": "^0.3.103",
38
- "@volter/teams": "0.2.95",
35
+ "@volter/supercode-teams": "^0.3.345",
36
+ "@volter/supercode-terminal": "^0.3.10",
37
+ "@volter/supercode-harness-sdk": "^0.3.104",
38
+ "@volter/teams": "0.2.98",
39
39
  "@volter/ztrack": "^2.4.23",
40
40
  "yaml": "^2.9.0"
41
41
  },
@@ -61,5 +61,6 @@
61
61
  "board",
62
62
  "apply",
63
63
  "README.md"
64
- ]
64
+ ],
65
+ "gitHead": "db4a88b4d2f670654fa8e57dbc3e93c7daa8d2bb"
65
66
  }
package/worker-route.mjs CHANGED
@@ -27,9 +27,9 @@
27
27
  //
28
28
  // A route this table does not have is refused by name at launch, never run on the worker's default:
29
29
  // that would spend on a model nobody chose.
30
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
30
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
31
31
  import { join } from 'node:path';
32
- import { MODEL_ARGUMENTS } from './activation.mjs';
32
+ import { MODEL_ARGUMENTS, materializePersona, materializeSkills } from './activation.mjs';
33
33
 
34
34
  /** The Codex provider id a Hermes provider is served under. */
35
35
  const PROVIDER_ID = { custom: 'hermes_custom', openai: 'openai', 'openai-codex': 'hermes_codex' };
@@ -75,6 +75,7 @@ export function workerRoute(profile, worker, vault) {
75
75
  const forward = vault?.get?.('HERMES_CODEX_BASE_URL') ?? process.env.HERMES_CODEX_BASE_URL;
76
76
  if (!forward) throw new Error('model.provider openai-codex needs the Codex forward (HERMES_CODEX_BASE_URL in the home\'s .env, which the kit\'s start writes); refused rather than run on a login the worker would have to hold');
77
77
  table.base_url = forward;
78
+ table.requires_openai_auth = false;
78
79
  table.env_key = LITERAL_KEY_ENV;
79
80
  env[LITERAL_KEY_ENV] = 'valve';
80
81
  writeProviderTable(join(profile.dir, 'codex'), id, table);
@@ -126,7 +127,7 @@ function writeProviderTable(home, id, table) {
126
127
  const file = join(home, 'config.toml');
127
128
  const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
128
129
  const header = `[model_providers.${id}]`;
129
- const block = [header, ...Object.entries(table).map(([k, v]) => `${k} = ${JSON.stringify(String(v))}`)].join('\n');
130
+ const block = [header, ...Object.entries(table).map(([k, v]) => `${k} = ${typeof v === 'boolean' ? String(v) : JSON.stringify(String(v))}`)].join('\n');
130
131
  const lines = text.split('\n');
131
132
  const start = lines.findIndex((line) => line.trim() === header);
132
133
  let next;
@@ -138,3 +139,30 @@ function writeProviderTable(home, id, table) {
138
139
  }
139
140
  if (next !== text) writeFileSync(file, next, { mode: 0o600 });
140
141
  }
142
+
143
+ /** The headful Codex lane must never fall back to the shared ChatGPT login.
144
+ * This returns only the configured forward's stand-in key and provider flags;
145
+ * it neither copies a credential nor starts a program or login. */
146
+ export function codexPaneRoute(profile, worker, vault) {
147
+ if (worker?.harness !== 'codex' || hermesModel(profile)?.provider !== 'openai-codex')
148
+ throw new Error('headful Codex requires model.provider openai-codex and its configured HERMES_CODEX_BASE_URL; no shared-login pane was launched');
149
+ if (worker.home === 'user') throw new Error('headful Codex requires a profile home; no shared-login pane was launched');
150
+ const forward = vault?.get?.('HERMES_CODEX_BASE_URL') ?? process.env.HERMES_CODEX_BASE_URL;
151
+ if (!forward) throw new Error('headful Codex requires the configured HERMES_CODEX_BASE_URL; no shared-login pane was launched');
152
+ let endpoint;
153
+ try { endpoint = new URL(forward); } catch { throw new Error('the configured Codex forward is not an HTTP endpoint; no pane was launched'); }
154
+ if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password)
155
+ throw new Error('the configured Codex forward must be HTTP(S) without URL credentials; no pane was launched');
156
+ const home = join(profile.dir, 'codex');
157
+ if (Object.hasOwn(worker.env ?? {}, 'CODEX_HOME') && worker.env.CODEX_HOME !== home)
158
+ throw new Error('headful Codex has a different worker.env.CODEX_HOME; no shared-login pane was launched');
159
+ // lstat includes a dangling auth symlink. Do not read, unlink or migrate it.
160
+ try {
161
+ lstatSync(join(home, 'auth.json'));
162
+ throw new Error('the headful Codex profile home holds an auth.json entry; a login-free home is required and no credential was changed');
163
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
164
+ materializePersona(profile);
165
+ materializeSkills(profile);
166
+ const route = workerRoute(profile, worker, vault);
167
+ return { ...route, env: { CODEX_HOME: home, ...route.env } };
168
+ }