@volter/supercode-orchestrator 0.5.205 → 0.5.207
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +278 -97
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +305 -80
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +92 -27
- package/board/files.mjs +17 -3
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +8 -2
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/notices.mjs +5 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +84 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +96 -31
- package/board/ztrack.mjs +207 -32
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
package/connection.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
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';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { fromContext } from '@volter/teams/context';
|
|
8
|
+
import { supercodeHome } from '@volter/teams/home';
|
|
9
|
+
|
|
10
|
+
export const MODULE_APPS = ['arcs-rooms','orchestration-rh2'];
|
|
11
|
+
export function privateJson(path, value) {
|
|
12
|
+
mkdirSync(dirname(path),{recursive:true,mode:0o700});
|
|
13
|
+
const pending=`${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
14
|
+
writeFileSync(pending,JSON.stringify(value,null,2)+'\n',{mode:0o600});renameSync(pending,path);chmodSync(path,0o600);
|
|
15
|
+
}
|
|
16
|
+
export async function connectionCall(record, side, method, path, body, token) {
|
|
17
|
+
const origin=side==='teams'?record.teams.server:record.origin;
|
|
18
|
+
// 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
|
+
}
|
|
29
|
+
const began=Date.now();
|
|
30
|
+
const response=await fetch(`${origin}${path}`,{method,redirect:'manual',signal:AbortSignal.timeout(path.endsWith('issuer-checks')?30000:9000),
|
|
31
|
+
headers:{accept:'application/json',...(token?{authorization:`Bearer ${token}`} : {}),
|
|
32
|
+
...(side==='rh2'?{origin:record.origin,'x-rh2-organization':record.organization}:{}),
|
|
33
|
+
...(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')}`}:{})},
|
|
35
|
+
...(body===undefined?{}:{body:JSON.stringify(body)})});
|
|
36
|
+
const text=await response.text();
|
|
37
|
+
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})`);}
|
|
39
|
+
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
|
+
return payload;
|
|
42
|
+
}
|
|
43
|
+
export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
|
|
44
|
+
export function generateModuleConfig(root, record, grant) {
|
|
45
|
+
const folder=dirname(connectionPath(root,record.id)), dir=join(folder,grant.name);
|
|
46
|
+
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
47
|
+
const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
|
|
48
|
+
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'},
|
|
50
|
+
teams:{server:record.teams.server,team:record.teams.team,source:record.source,tokenEnv:'TEAMS_TOKEN'},
|
|
51
|
+
envFile:custody,stateFile:join(dir,'state.json'),defaultFloor:record.floor,
|
|
52
|
+
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'},
|
|
53
|
+
alerts:{role:record.alertRole??'admin'},standingFile:join(folder,'standing.json')};
|
|
54
|
+
privateJson(configPath,config);
|
|
55
|
+
privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),{app:grant.name,connection:record.id,configPath,moduleRoot:record.moduleRoot,
|
|
56
|
+
origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
|
|
57
|
+
return configPath;
|
|
58
|
+
}
|
|
59
|
+
export function startConnectionRunner(root,id) {
|
|
60
|
+
const recordPath=connectionPath(root,id);
|
|
61
|
+
if(!existsSync(recordPath)||!connectionCanRun(JSON.parse(readFileSync(recordPath,'utf8'))))return null;
|
|
62
|
+
const logPath=join(dirname(connectionPath(root,id)),'runner.log'),fd=openSync(logPath,'a',0o600);
|
|
63
|
+
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
|
+
closeSync(fd);child.unref();return child.pid;
|
|
65
|
+
}
|
|
66
|
+
export function connectionCanRun(record){return record.preparing===false&&record.paused===false&&typeof record.connectedAt==='string';}
|
|
67
|
+
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)');
|
|
69
|
+
const root=resolve(flags.root), ticket=JSON.parse(readFileSync(flags['ticket-file'],'utf8'));
|
|
70
|
+
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');
|
|
72
|
+
// An explicit local credential input is useful for an owned stack; a normal connect uses its existing Teams context.
|
|
73
|
+
let teams,owner;
|
|
74
|
+
if(flags['teams-token-file']){
|
|
75
|
+
if(!flags['teams-url']||!flags.team)throw new Error('--teams-token-file needs --teams-url and --team');
|
|
76
|
+
teams={server:new URL(flags['teams-url']).origin,team:flags.team};owner=readFileSync(flags['teams-token-file'],'utf8').trim();
|
|
77
|
+
}else{
|
|
78
|
+
const {context}=fromContext({supercodeHome:supercodeHome(),name:flags.context});
|
|
79
|
+
const {WorkspaceContexts}=await import('@volter/teams/context');
|
|
80
|
+
teams={server:context.origin,team:context.team_id};owner=new WorkspaceContexts(supercodeHome()).credential(context.credential_ref).token;
|
|
81
|
+
}
|
|
82
|
+
for(const origin of [ticket.origin,ticket.issuer,teams.server]){
|
|
83
|
+
const url=new URL(origin);if(url.origin!==origin||url.username||url.password||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))throw new Error('connection endpoints must be exact HTTPS origins or loopback HTTP');
|
|
84
|
+
}
|
|
85
|
+
const id=createHash('sha256').update(`${ticket.scope}\0${ticket.issuer}\0${ticket.organization}\0${teams.server}\0${teams.team}\0${flags.source}`).digest('hex').slice(0,24);
|
|
86
|
+
const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
|
|
87
|
+
if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('connection ticket expired; apply the organization file again');
|
|
88
|
+
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:[]};
|
|
90
|
+
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');
|
|
92
|
+
const lock=join(dirname(path),'runner.lock');
|
|
93
|
+
if(prior&&existsSync(lock)) {
|
|
94
|
+
const running=JSON.parse(readFileSync(lock,'utf8'));
|
|
95
|
+
let alive=false;try{process.kill(running.pid,0);alive=true;}catch(error){if(error.code!=='ESRCH')throw error;}
|
|
96
|
+
if(alive) {
|
|
97
|
+
if(flags['no-start'])throw new Error('stop this connection runner before preparing an owner handover');
|
|
98
|
+
return {connection:id,root,modules:record.grants.map(g=>g.name),runner:running.pid,calls:[]};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
record.standingSelection=flags['standing-file']?JSON.parse(readFileSync(flags['standing-file'],'utf8')):(ticket.grants.find(g=>g.name==='arcs-rooms')?.rooms??[]).map(seat=>({profile:seat.key,roomKey:seat.key,roleKeys:seat.roleKeys}));
|
|
102
|
+
if(record.standingSelection.some(row=>!/^[-a-z0-9]{1,64}$/.test(row.profile??'')||!/^[-a-z0-9]{1,64}$/.test(row.roomKey??'')||row.session||row.agent))throw new Error('standing selections name a profile and Room key; agent/session ids come from the home');
|
|
103
|
+
// Registry entries may become visible during setup. They are never permission to start a writer.
|
|
104
|
+
record.preparing=true;record.paused=true;
|
|
105
|
+
privateJson(path,record);
|
|
106
|
+
for(const selected of ticket.grants){
|
|
107
|
+
let grant=record.grants.find(g=>g.name===selected.name);
|
|
108
|
+
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);}
|
|
110
|
+
if(!grant.rh2){
|
|
111
|
+
const recovering=typeof grant.redemption==='string';
|
|
112
|
+
grant.redemption??=randomBytes(32).toString('base64url');privateJson(path,record);
|
|
113
|
+
const body={clientId:selected.clientId,clientSecret:selected.clientSecret,code:selected.code,redemption:grant.redemption};
|
|
114
|
+
let exchanged;
|
|
115
|
+
if(recovering){
|
|
116
|
+
try{exchanged=await connectionCall(record,'rh2','POST','/api/v3/automation/apps/oauth/access',{...body,recover:true});}
|
|
117
|
+
catch(error){
|
|
118
|
+
if(error.code!=='invalid-grant')throw error;
|
|
119
|
+
if(!(Date.parse(selected.codeExpiresAt??ticket.expiresAt)>Date.now()))throw new Error(`${grant.name}: receipt-bound recovery refused; it needs the original receiver receipt, current client secret, live installation session and installer standing within the 24-hour recovery policy. An owner must explicitly reconsent after that window; reapply cannot replace redeemed custody.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// A recovery refusal can also mean the first request never committed. Only a live unspent code may
|
|
123
|
+
// enter first exchange; the server refuses this branch if a different receipt already spent it.
|
|
124
|
+
if(!exchanged){
|
|
125
|
+
if(!(Date.parse(selected.codeExpiresAt??ticket.expiresAt)>Date.now()))throw new Error(`${grant.name}: install code expired; apply the organization file again for unredeemed consent`);
|
|
126
|
+
exchanged=await connectionCall(record,'rh2','POST','/api/v3/automation/apps/oauth/access',body);
|
|
127
|
+
}
|
|
128
|
+
grant.rh2=exchanged.data;privateJson(path,record);
|
|
129
|
+
}
|
|
130
|
+
if(selected.teamsManifest&&!grant.teams){
|
|
131
|
+
if(!grant.registration){grant.stagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.registration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{...selected.teamsManifest,name:`${selected.teamsManifest.name} (${id.slice(0,8)})`,secret:grant.stagedSecret},owner)).data;privateJson(path,record);}
|
|
132
|
+
grant.registration.secret=grant.stagedSecret;
|
|
133
|
+
if(!grant.consent){grant.stagedCode??=`sci_${randomBytes(32).toString('base64url')}`;privateJson(path,record);grant.consent=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps/${grant.registration.id}/installations`,{redirect_url:selected.teamsManifest.redirect_urls[0],state:id,actions:selected.teamsManifest.actions,code:grant.stagedCode},owner)).data;privateJson(path,record);}
|
|
134
|
+
grant.consent.code=grant.stagedCode;
|
|
135
|
+
grant.stagedCredential??=newInstallationToken();privateJson(path,record);
|
|
136
|
+
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
|
+
delete grant.consent;delete grant.registration.secret;privateJson(path,record);
|
|
138
|
+
}
|
|
139
|
+
generateModuleConfig(root,record,grant);
|
|
140
|
+
}
|
|
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);}
|
|
142
|
+
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
|
+
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
|
+
privateJson(join(dirname(path),'standing.json'),[]);
|
|
147
|
+
record.connectedAt??=new Date().toISOString();
|
|
148
|
+
record.preparing=false;record.paused=Boolean(flags['no-start']);privateJson(path,record);
|
|
149
|
+
const pid=record.paused?null:startConnectionRunner(root,id);
|
|
150
|
+
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
|
+
}
|
|
152
|
+
|
|
153
|
+
export function newInstallationToken(){return `sct_cred_${randomBytes(16).toString('hex')}.${randomBytes(32).toString('base64url')}`;}
|
package/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Types for the orchestrator IR — docs/ORCHESTRATOR-IR.md §2 is normative.
|
|
2
2
|
//
|
|
3
|
-
// ONT-6: every record shape is the
|
|
4
|
-
// (`
|
|
3
|
+
// ONT-6: every record shape is the committed ontology contract's
|
|
4
|
+
// (`sdk/interchange/ontology-schemas.json`), reached
|
|
5
5
|
// through `./ontology.d.ts` — written by
|
|
6
6
|
// `node sdk/orchestrator/scripts/generate-types.mjs` from `supercode ontology schema`.
|
|
7
|
-
// Declare no record here;
|
|
7
|
+
// Declare no record here; update the contract and regenerate.
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
10
|
Access,
|
|
@@ -137,6 +137,7 @@ export function localForkAgent(options: {root: Path; org: string; server: string
|
|
|
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. */
|
|
138
138
|
export function handOffAgents(root: Path, options?: {context?: string}): Array<{agent: string; error: string}>;
|
|
139
139
|
export function localAgentRecord(root: Path, org: string, id: string): LocalAgent | null;
|
|
140
|
+
export function homeAgentId(root: Path, scope: {org: string; server: string; name: string; profile?: string}): string | null;
|
|
140
141
|
export function localAgentSessions(root: Path, org: string, id: string): Array<{session: string; machine: string | null; ended_at: number | null; end_reason: string | null}>;
|
|
141
142
|
export function agentProfileMismatch(intent: unknown, resolved: unknown): import('@volter/teams/agent-profile').ProfileComparison;
|
|
142
143
|
|
|
@@ -439,8 +440,9 @@ export function mountOrchestratorMcp(o: {
|
|
|
439
440
|
root: Path; profile: Profile; harness: string; surfaceKey?: string | null;
|
|
440
441
|
launch?: { program: string; arguments?: string[]; env?: Record<string, string> };
|
|
441
442
|
socketPath?: Path; serverPath?: Path; program?: string;
|
|
442
|
-
/** The agent the session is;
|
|
443
|
-
|
|
443
|
+
/** The Teams id of the agent the session is; the rh2-agent record installed for that id (`--agent`) is mounted beside
|
|
444
|
+
* the orchestrator server as `rh2-agent`. */
|
|
445
|
+
agentId?: string | null;
|
|
444
446
|
}): { door: McpDoor; ran: string; launch: unknown; mcp_servers: McpServerLaunch[] | null; path: Path | null };
|
|
445
447
|
|
|
446
448
|
export function socketPathFor(root: Path): Path;
|
package/index.mjs
CHANGED
|
@@ -41,5 +41,5 @@ export { hermesAccess, recordIncident, hermesToolEnv, hermesInstall } from './he
|
|
|
41
41
|
export { holdGatewayLock, gatewayLockHeld, acquireTokenLock, releaseTokenLock, looksLikeGateway, TOKEN_SCOPES } from './hermes-locks.mjs';
|
|
42
42
|
|
|
43
43
|
export { resolveAgentProfile, profileDifferences, sessionAgent } from "./agent-profiles.mjs";
|
|
44
|
-
export { agentScope, localCardAgent, localProfileAgent, localAgentRecord, localAgentSessions, declareAgentIntent, agentRunForKey, agentIntent, assignAgent, reassignAgent, activeAgentAssignments, finishAgentAssignment, recordAgentRun, recordNativeAgentSession, agentRunForSession, agentPreamble, agentEnvironment, agentProfileMismatch, localForkAgent, handOffAgents} from "./agents.mjs";
|
|
44
|
+
export { agentScope, localCardAgent, localProfileAgent, localAgentRecord, homeAgentId, localAgentSessions, declareAgentIntent, agentRunForKey, agentIntent, assignAgent, reassignAgent, activeAgentAssignments, finishAgentAssignment, recordAgentRun, recordNativeAgentSession, agentRunForSession, agentPreamble, agentEnvironment, agentProfileMismatch, localForkAgent, handOffAgents} from "./agents.mjs";
|
|
45
45
|
export { agentStateHome,materializeAgentHome } from './agent-home.mjs';
|
package/installed-apps.mjs
CHANGED
|
@@ -9,9 +9,11 @@ import { delimiter, isAbsolute, join } from 'node:path';
|
|
|
9
9
|
export const APPS_DIR = 'apps';
|
|
10
10
|
/** The apps this orchestrator can host, by name: what each installs and which adapter it attaches. */
|
|
11
11
|
export const INSTALLABLE_APPS = Object.freeze({
|
|
12
|
+
'arcs-rooms': { side: 'rh2', module: true, describe: 'arcs-rooms installed module' },
|
|
13
|
+
'orchestration-rh2': { side: 'rh2', module: true, describe: 'orchestration-rh2 installed module' },
|
|
12
14
|
'room-chat': { side: 'rh2', adapter: 'rh2', describe: 'Room chat → orchestrator: a person\'s line in an RH2 Room reaches the agent seated there' },
|
|
13
|
-
// An agent the RH2 organization declares is one participant there: its chat
|
|
14
|
-
//
|
|
15
|
+
// An agent the RH2 organization declares is one participant there: its chat and its call are its own agent principal,
|
|
16
|
+
// never an automation beside it. One record per agent, bound to its Teams id (`rh2-agent.<name>`, `agentId`).
|
|
15
17
|
'rh2-agent': { side: 'rh2', adapter: 'rh2', perProfile: true, describe: 'An agent the RH2 organization declares, in its Rooms as its own principal' },
|
|
16
18
|
});
|
|
17
19
|
|
|
@@ -26,16 +28,18 @@ export function installedApps(root) {
|
|
|
26
28
|
}).filter((record) => INSTALLABLE_APPS[record.app ?? record.name]);
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
/** An RH2 agent's own identity
|
|
30
|
-
*
|
|
31
|
-
|
|
31
|
+
/** An RH2 agent's own identity: the token the organization's admin issued that agent principal (RH2's agent-token door),
|
|
32
|
+
* kept once RH2 answers it in that organization, for the one agent it is (`--agent`, its Teams id). It is found by that
|
|
33
|
+
* id alone (installedAgentRecord): a name or profile another agent shares never reaches it. */
|
|
34
|
+
export async function installAgent({ root, origin, organization, principal, token, profile, agent, fetchImpl = globalThis.fetch }) {
|
|
32
35
|
if (!profile) throw new Error('an RH2 agent is installed for a profile (--profile <agent>)');
|
|
36
|
+
if (!/^agent_[A-Za-z0-9_-]+$/.test(String(agent))) throw new Error('--agent is the Teams id of the agent this identity is (agent_…)');
|
|
33
37
|
if (!/^prn_/.test(String(principal))) throw new Error('--principal is the RH2 agent\'s principal id (prn_…)');
|
|
34
38
|
if (!rh2Origin(origin)) throw new Error('the RH2 origin is an https URL');
|
|
35
39
|
const base = new URL(origin).origin;
|
|
36
40
|
const response = await fetchImpl(`${base}/api/v3/rooms`, { headers: { authorization: `Bearer ${token}`, 'x-rh2-organization': organization, origin: base, accept: 'application/json' } });
|
|
37
41
|
if (!response.ok) throw new Error(`RH2 refused the agent's token in ${organization}: HTTP ${response.status}`);
|
|
38
|
-
const record = { app: 'rh2-agent', side: 'rh2', origin: base, organizationId: organization, principalId: principal, profile, token, installedAt: new Date().toISOString() };
|
|
42
|
+
const record = { app: 'rh2-agent', side: 'rh2', origin: base, organizationId: organization, principalId: principal, agentId: agent, profile, token, installedAt: new Date().toISOString() };
|
|
39
43
|
mkdirSync(join(root, APPS_DIR), { recursive: true, mode: 0o700 });
|
|
40
44
|
const path = recordPath(root, 'rh2-agent', profile);
|
|
41
45
|
const pending = `${path}.${process.pid}.tmp`;
|
|
@@ -44,19 +48,17 @@ export async function installAgent({ root, origin, organization, principal, toke
|
|
|
44
48
|
return { ...record, name: `rh2-agent.${profile}`, token: undefined };
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
/** The rh2-agent record
|
|
48
|
-
* or null.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
51
|
+
/** The rh2-agent record that is the identity of `agentId` (an agent's Teams id, as `apps install rh2-agent --agent`
|
|
52
|
+
* named it), or null. It is found by that id alone, never by a name or profile another agent may share (a later
|
|
53
|
+
* generation of a card's agent, another home's root agent named after the same folder), and only when exactly one
|
|
54
|
+
* record names it. Its file name (`--profile`) is a label. */
|
|
55
|
+
export function installedAgentRecord(root, agentId) {
|
|
56
|
+
if (typeof agentId !== 'string' || !/^agent_[A-Za-z0-9_-]+$/.test(agentId)) return null;
|
|
57
|
+
let records;
|
|
58
|
+
try { records = installedApps(root); } catch { return null; }
|
|
59
|
+
// the origin install accepted (https, or a loopback for a local RH2), held again wherever the record is used
|
|
60
|
+
const own = records.filter((record) => record.app === 'rh2-agent' && record.agentId === agentId && record.token && rh2Origin(record.origin));
|
|
61
|
+
return own.length === 1 ? own[0] : null;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
/** Whether `origin` is one an RH2 app may be installed for: https, or a loopback for a local RH2. */
|
|
@@ -81,6 +83,7 @@ export function rh2AgentEnv(record) {
|
|
|
81
83
|
/** Exchange an RH2 install code for the installation's token and keep the record. */
|
|
82
84
|
export async function installApp({ root, name, origin, clientId, clientSecret, code, profile = 'default', fetchImpl = globalThis.fetch }) {
|
|
83
85
|
const app = INSTALLABLE_APPS[name];
|
|
86
|
+
if(app?.module)throw new Error('module apps are consented and installed together by orchestrator connect');
|
|
84
87
|
if (!app) throw new Error(`no installable app named ${name} (${Object.keys(INSTALLABLE_APPS).join(', ')})`);
|
|
85
88
|
if (!rh2Origin(origin)) throw new Error('the RH2 origin is an https URL');
|
|
86
89
|
const base = new URL(origin).origin;
|
|
@@ -104,4 +107,12 @@ export function removeApp(root, name) {
|
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
/** What `apps list` shows: never the token. */
|
|
107
|
-
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 });
|
|
110
|
+
export const appView = (record) => ({ name: record.name, side: record.side, origin: record.origin, organization: record.organizationId, principal: record.principalId, agent: record.agentId, installation: record.installationId, profile: record.profile, expires_at: record.expiresAt });
|
|
111
|
+
|
|
112
|
+
/** The same home's app registry drives module hosting after a daemon restart. */
|
|
113
|
+
export async function startInstalledModules(root) {
|
|
114
|
+
if(!root)return [];
|
|
115
|
+
const {startConnectionRunner}=await import('./connection.mjs');
|
|
116
|
+
const ids=[...new Set(installedApps(root).filter(app=>INSTALLABLE_APPS[app.app]?.module).map(app=>app.connection))];
|
|
117
|
+
return ids.map(id=>({connection:id,pid:startConnectionRunner(root,id)})).filter(row=>row.pid!==null);
|
|
118
|
+
}
|
package/loop.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { basename, join, dirname } from 'node:path';
|
|
|
9
9
|
import { step, leaseId } from './reducer.mjs';
|
|
10
10
|
import * as events from './events.mjs';
|
|
11
11
|
import { read, save, retainWorld, releaseWorld } from './orchestration.mjs';
|
|
12
|
-
import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, declareWorkerHome, approvalPolicy, sandboxArguments, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
|
|
12
|
+
import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, workerCwd, declareWorkerFolder, declareWorkerHome, 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
15
|
import { hermesSessionKey, homeChannel } from './ir.mjs';
|
|
@@ -134,7 +134,19 @@ export class Orchestrator {
|
|
|
134
134
|
|
|
135
135
|
/** The folder written from state, and each profile's `cron/jobs.json` as that write left it. */
|
|
136
136
|
async save() {
|
|
137
|
-
await save(this.state, this.state.root);
|
|
137
|
+
const { rejected } = await save(this.state, this.state.root);
|
|
138
|
+
// An entry the store could not read was written as the home last held it (or, never held, left out), and is again
|
|
139
|
+
// at every save while it stays as it is; the log names it once per reason, not once per save.
|
|
140
|
+
for (const { collection, key, reason, kept_stored: kept } of rejected) {
|
|
141
|
+
const said = `${collection}\u0000${key}\u0000${reason}`;
|
|
142
|
+
if ((this.rejectedSaid ??= new Set()).has(said)) continue;
|
|
143
|
+
this.rejectedSaid.add(said);
|
|
144
|
+
// Said plainly: this daemon keeps its unreadable version, and the home on disk holds another, so its next start
|
|
145
|
+
// takes up what is on disk, not what runs now.
|
|
146
|
+
this.log('warn', kept
|
|
147
|
+
? `${collection} ${key} could not be saved (${reason}): the home keeps its stored version of it, and the orchestrator will restart from that stored version, not from what runs now`
|
|
148
|
+
: `${collection} ${key} could not be saved (${reason}): it is left out of the home, and the orchestrator will restart without it`);
|
|
149
|
+
}
|
|
138
150
|
this.jobsWritten = this.jobsFiles();
|
|
139
151
|
}
|
|
140
152
|
|
|
@@ -325,6 +337,8 @@ export class Orchestrator {
|
|
|
325
337
|
case 'budget_refused': return this.tellBudgetRefusal(effect);
|
|
326
338
|
case 'save': await this.save(); return;
|
|
327
339
|
case 'log': this.log(effect.level, effect.message); return;
|
|
340
|
+
// an operator's refusal, answered by the operator door (operator.mjs); recorded here
|
|
341
|
+
case 'refused': this.log('info', effect.message); return;
|
|
328
342
|
default: this.log('warn', `unknown effect ${effect.type}`);
|
|
329
343
|
}
|
|
330
344
|
}
|
|
@@ -398,14 +412,17 @@ export class Orchestrator {
|
|
|
398
412
|
/** Wait for admitted host tasks; useful for deterministic tests and graceful shutdown. */
|
|
399
413
|
async drainHosted() { await Promise.allSettled([...this.hostedTasks]); }
|
|
400
414
|
|
|
401
|
-
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 }) {
|
|
415
|
+
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,concurrent=false }) {
|
|
402
416
|
const profile = this.state.profiles[profileName];
|
|
403
417
|
const lid = leaseId(profileName, keyString);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
let launch,agent,scope,context,agentPrompt,main,agentHome;
|
|
418
|
+
// Every step of a start is inside one of the two failure handlers below, which end the session with the reason
|
|
419
|
+
// (`start failed: …`, kept as its end_detail): a step outside them failed the effect with the session left starting.
|
|
420
|
+
let launch,agent,scope,context,agentPrompt,main,agentHome,cwd;
|
|
408
421
|
try {
|
|
422
|
+
materializePersona(profile);
|
|
423
|
+
materializeSkills(profile);
|
|
424
|
+
// the profile's own subfolder is made; a missing folder outside it refuses the start, naming it (workerCwd)
|
|
425
|
+
cwd = workerCwd(profile, worker.cwd);
|
|
409
426
|
// The launch asks Teams nothing (D137): the agent is this home's minted identity, its declaration handed to this
|
|
410
427
|
// machine's daemon, its reader context the one this machine holds (the daemon delegates one when it binds the
|
|
411
428
|
// session), its earlier sessions the ones this home ran.
|
|
@@ -419,7 +436,8 @@ export class Orchestrator {
|
|
|
419
436
|
agent??=await localProfileAgent({root:this.state.root,org:scope.org,server:scope.server,profiles:this.state.profiles,profile:profileName,agent:agentName,declarations:this.state.agents});
|
|
420
437
|
if(!scope.offline)for(const pending of await handOffAgents(this.state.root,{context}))this.log('warn',`agent ${pending.agent}: ${pending.error}`);
|
|
421
438
|
agent.intended.resolved.worker={...agent.intended.resolved.worker,...worker,cwd};
|
|
422
|
-
main
|
|
439
|
+
// A session started on request (`sessions.launch`) is the agent working elsewhere: never its main session.
|
|
440
|
+
main=concurrent?false:new_fork||!agent.identity.main_session;
|
|
423
441
|
if(resume_runtime_id&&!new_fork){
|
|
424
442
|
const owner=agentRunForSession(this.state.root,[`${worker.harness}:${resume_runtime_id}`]);
|
|
425
443
|
if(owner&&owner.agent_id!==agent.identity.id)throw new Error('the resumed session is not owned by this profile agent');
|
|
@@ -437,7 +455,9 @@ export class Orchestrator {
|
|
|
437
455
|
const tools = worker.harness === 'hermes' ? {} : hermesToolEnv(agentHome);
|
|
438
456
|
const env = { ...tools, ...activationEnv({ root: this.state.root, profile, surfaceKey: keyString, vault: this.vault }), ...route.env,...agentEnvironment(agent,{root:this.state.root,context:localReaderContext(scope.org,agent.identity.id)??undefined,main}) };
|
|
439
457
|
env.HERMES_HOME=agentHome;
|
|
440
|
-
|
|
458
|
+
// its folder a sync root first (a home is declared on the roots covering its folder), then its config home
|
|
459
|
+
const say = (level, message) => this.log(level, message);
|
|
460
|
+
void declareWorkerFolder({ cwd, root: this.state.root, profile: profileName, profileDirs: Object.values(this.state.profiles ?? {}).map((each) => each?.dir), context, scope, log: say }).then(() => declareWorkerHome({ harness: worker.harness, env, cwd, log: say }));
|
|
441
461
|
// All sessions of this agent carry its state; a shared profile contributes configuration only.
|
|
442
462
|
const memory = INSTRUCTION_ARGUMENTS[worker.harness] ? await memorySnapshot(agentHome).catch((error) => { this.log('warn', `memory ${keyString}: ${error.message}`); return ''; }) : '';
|
|
443
463
|
// a worker in the user's own home carries its persona there too (its config home's CLAUDE.md is the user's)
|
|
@@ -449,20 +469,20 @@ export class Orchestrator {
|
|
|
449
469
|
this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: null, ev: { kind: 'error', message: `start failed: ${error.message}` }, at: this.clock() }));
|
|
450
470
|
return;
|
|
451
471
|
}
|
|
452
|
-
|
|
453
|
-
// this conversation, mounted through whatever door this harness publishes.
|
|
454
|
-
const mount = mountOrchestratorMcp({
|
|
455
|
-
root: this.state.root, profile, harness: worker.harness, surfaceKey: keyString, launch,
|
|
456
|
-
socketPath: this.bridge?.path ?? socketPathFor(this.state.root), vault: this.vault,
|
|
457
|
-
// the agent's own sessions speak in RH2 as it; a worker launched to act for it (agent_as) does not
|
|
458
|
-
agent: agent_as ? null : agent?.identity?.name ?? null,
|
|
459
|
-
});
|
|
460
|
-
this.log('info', `mcp mount (${worker.harness}, ${mount.door}): ${mount.ran}`);
|
|
461
|
-
if (resume_runtime_id) resume_runtime_id = await this.continueFromHermes(profile, keyString, worker, resume_runtime_id, cwd,agentHome);
|
|
462
|
-
// read before the runtime exists: a runtime emits from the moment it is made
|
|
463
|
-
const steer = Boolean((await this.descriptorFor(worker.harness).catch(() => null))?.runtime?.capabilities?.steer);
|
|
464
|
-
let runtime;
|
|
472
|
+
let runtime, steer;
|
|
465
473
|
try {
|
|
474
|
+
// §7: the model-facing door. One MCP server per worker session, bound to
|
|
475
|
+
// this conversation, mounted through whatever door this harness publishes.
|
|
476
|
+
const mount = mountOrchestratorMcp({
|
|
477
|
+
root: this.state.root, profile, harness: worker.harness, surfaceKey: keyString, launch,
|
|
478
|
+
socketPath: this.bridge?.path ?? socketPathFor(this.state.root), vault: this.vault,
|
|
479
|
+
// the agent's own sessions speak in RH2 as it; a worker launched to act for it (agent_as) does not
|
|
480
|
+
agentId: agent_as ? null : agent?.identity?.id ?? null,
|
|
481
|
+
});
|
|
482
|
+
this.log('info', `mcp mount (${worker.harness}, ${mount.door}): ${mount.ran}`);
|
|
483
|
+
if (resume_runtime_id) resume_runtime_id = await this.continueFromHermes(profile, keyString, worker, resume_runtime_id, cwd,agentHome);
|
|
484
|
+
// read before the runtime exists: a runtime emits from the moment it is made
|
|
485
|
+
steer = Boolean((await this.descriptorFor(worker.harness).catch(() => null))?.runtime?.capabilities?.steer);
|
|
466
486
|
const policy = approvalPolicy(profile, worker.harness);
|
|
467
487
|
const params = { harness: worker.harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}) };
|
|
468
488
|
if (resume_runtime_id && (await this.descriptorFor(worker.harness).catch(() => null))?.runtime?.capabilities?.resume_session) {
|
|
@@ -480,23 +500,58 @@ export class Orchestrator {
|
|
|
480
500
|
this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: null, ev: { kind: 'error', message: `start failed: ${error.message}` }, at: this.clock() }));
|
|
481
501
|
return;
|
|
482
502
|
}
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
503
|
+
// The runtime runs from here, so every later step is one whose failure ends it: until `started` is queued, the
|
|
504
|
+
// runtime is closed here and the start fails as every earlier step's does; after, the error is the runtime's own,
|
|
505
|
+
// and the session's end closes it (its lease is held by then). None leaves a session `starting` with a runtime
|
|
506
|
+
// nobody watches.
|
|
507
|
+
let started = false;
|
|
508
|
+
try {
|
|
509
|
+
// the agent run is recorded before the runtime is held here, so a refused record (agents.mjs: a busy store, an
|
|
510
|
+
// ownership change) leaves nothing of it behind
|
|
511
|
+
const session=`${worker.harness}:${runtime.handle.runtime_id}`;
|
|
512
|
+
recordAgentRun(this.state.root,scope.org,agent.identity.id,{session,machine:null,run:run_key??lid,profile:agent.intended.resolved,main,starterSession:starter_session,conversation:!run_key});
|
|
513
|
+
const previous = this.runtimes.get(lid);
|
|
514
|
+
if (previous && previous !== runtime) this.retired.set(previous.handle.runtime_id, previous);
|
|
515
|
+
this.runtimes.set(lid, runtime);
|
|
516
|
+
if(!INSTRUCTION_ARGUMENTS[worker.harness])(this.runtimeAgentPreambles??=new Map()).set(lid,agentPrompt);
|
|
517
|
+
// `started` is queued before the runtime can be heard (steer was read
|
|
518
|
+
// before the start), so none of its events arrives ahead of its lease
|
|
519
|
+
this.enqueue(events.worker({
|
|
520
|
+
profile: profileName, keyString, runtime_id: runtime.handle.runtime_id,
|
|
521
|
+
ev: { kind: 'started', runtime_id: runtime.handle.runtime_id, session_id: runtime.handle.runtime_id, connection: runtime.connection, harness: worker.harness, steer },
|
|
522
|
+
at: this.clock(),
|
|
523
|
+
}));
|
|
524
|
+
started = true;
|
|
525
|
+
this.watch(profileName, keyString, runtime);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
if (started) {
|
|
528
|
+
this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: runtime.handle.runtime_id, ev: { kind: 'error', message: `start failed: ${error.message}` }, at: this.clock() }));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (this.runtimes.get(lid) === runtime) this.runtimes.delete(lid);
|
|
532
|
+
try { await runtime.close(); } catch { /* already gone */ }
|
|
533
|
+
this.enqueue(events.worker({ profile: profileName, keyString, runtime_id: null, ev: { kind: 'error', message: `start failed: ${error.message}` }, at: this.clock() }));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
497
536
|
return runtime.handle;
|
|
498
537
|
}
|
|
499
538
|
|
|
539
|
+
/**
|
|
540
|
+
* A new concurrent session of this profile's agent, asked for on its behalf (operator `sessions.launch`): the agent is
|
|
541
|
+
* the one this home holds for the profile, and must be the one asked for. The session is composed by startRuntime
|
|
542
|
+
* exactly as every session of the agent is (persona, skills, home, model route, vault, instructions with its preamble
|
|
543
|
+
* for a concurrent session, persona and memory, the orchestrator's MCP door, the approval policy, the agent's
|
|
544
|
+
* environment); its first turn is its caller's first line to it.
|
|
545
|
+
*/
|
|
546
|
+
async prepareLaunch({ profile: profileName, agentId }) {
|
|
547
|
+
const profile = this.state.profiles[profileName];
|
|
548
|
+
if (!profile?.worker) throw new Error(`profile ${profileName} has no worker to start a session with`);
|
|
549
|
+
const context = process.env.SUPERCODE_CONTEXT, scope = agentScope(context);
|
|
550
|
+
const agent = await localProfileAgent({ root: this.state.root, org: scope.org, server: scope.server, profiles: this.state.profiles, profile: profileName, agent: profileName, declarations: this.state.agents });
|
|
551
|
+
if (agent.identity.id !== agentId) throw new Error(`profile ${profileName} runs agent ${agent.identity.id}, not ${agentId}`);
|
|
552
|
+
return { agent: agent.identity.id };
|
|
553
|
+
}
|
|
554
|
+
|
|
500
555
|
/** Fork through native handoff/materialization. The source remains retained and the child has its own id. */
|
|
501
556
|
async forkRuntime({profile:profileName,keyString,parent,locator:given,name,key,machine=null}) {
|
|
502
557
|
if(machine)throw new Error('fork this owned workspace on its execution host; move it through the workspace door afterward');
|
package/mcp/bridge.mjs
CHANGED
|
@@ -133,6 +133,18 @@ async function dispatchOperator(orch, { op, args, profile }, log) {
|
|
|
133
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
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
135
|
} catch (error) { answer = { ok: false, error: error.message }; }
|
|
136
|
+
} else if (op === 'sessions.launch') {
|
|
137
|
+
// A concurrent session of the profile's agent, on request: the agent is checked here, where the home's agents are;
|
|
138
|
+
// the start is the reducer's, ordered on the loop's queue like any other, and its refusal is this answer's.
|
|
139
|
+
try {
|
|
140
|
+
const a = args ?? {};
|
|
141
|
+
if (typeof a.agent_id !== 'string' || !a.agent_id || typeof a.key !== 'string' || !a.key) throw new Error('sessions.launch needs agent_id and key');
|
|
142
|
+
if (typeof a.opening !== 'string' || !a.opening.trim() || a.opening.length > 4000) throw new Error('sessions.launch needs the requester\'s opening message, up to 4000 characters');
|
|
143
|
+
// The agent's preamble is already in the session's instructions (startRuntime); the requester's own words are its
|
|
144
|
+
// first turn, and nothing else is.
|
|
145
|
+
await orch.prepareLaunch({ profile: name, agentId: a.agent_id });
|
|
146
|
+
answer = await applyOperator(orch.state, op, { key: a.key, agent_id: a.agent_id, input: a.opening.trim() }, name, orch.clock(), (event) => orch.dispatch(event));
|
|
147
|
+
} catch (error) { answer = { ok: false, error: error.message }; }
|
|
136
148
|
} else try {
|
|
137
149
|
answer = await applyOperator(orch.state, op, args ?? {}, name, orch.clock(), (event) => orch.dispatch(event));
|
|
138
150
|
} catch (error) {
|
|
@@ -102,11 +102,7 @@
|
|
|
102
102
|
},
|
|
103
103
|
"commit": {
|
|
104
104
|
"type": "string",
|
|
105
|
-
"description": "Exact pushed
|
|
106
|
-
},
|
|
107
|
-
"ci_run": {
|
|
108
|
-
"type": "integer",
|
|
109
|
-
"description": "GitHub Actions deployment run id for that exact commit."
|
|
105
|
+
"description": "Exact pushed source head for a subtask or independently reviewed arc."
|
|
110
106
|
},
|
|
111
107
|
"branch": {
|
|
112
108
|
"type": "string",
|
|
@@ -354,7 +350,7 @@
|
|
|
354
350
|
},
|
|
355
351
|
{
|
|
356
352
|
"name": "kanban_attachments",
|
|
357
|
-
"description": "List
|
|
353
|
+
"description": "List a task's attachment metadata. Supply attachment_id to read its bytes through the board as a bounded base64 range; no host filesystem access is needed. Carry the first response version on subsequent ranges.",
|
|
358
354
|
"inputSchema": {
|
|
359
355
|
"type": "object",
|
|
360
356
|
"properties": {
|
|
@@ -362,6 +358,26 @@
|
|
|
362
358
|
"type": "string",
|
|
363
359
|
"description": "Task id. If omitted, defaults to HERMES_KANBAN_TASK from the env (the task the dispatcher spawned you to work on)."
|
|
364
360
|
},
|
|
361
|
+
"attachment_id": {
|
|
362
|
+
"type": "integer",
|
|
363
|
+
"minimum": 1,
|
|
364
|
+
"description": "Read this attachment of the named task; omit to list metadata."
|
|
365
|
+
},
|
|
366
|
+
"offset": {
|
|
367
|
+
"type": "integer",
|
|
368
|
+
"minimum": 0,
|
|
369
|
+
"description": "Byte offset, default 0. A nonzero offset requires version."
|
|
370
|
+
},
|
|
371
|
+
"length": {
|
|
372
|
+
"type": "integer",
|
|
373
|
+
"minimum": 1,
|
|
374
|
+
"maximum": 1048576,
|
|
375
|
+
"description": "Maximum bytes in this range, default 1048576."
|
|
376
|
+
},
|
|
377
|
+
"version": {
|
|
378
|
+
"type": "string",
|
|
379
|
+
"description": "The first range's filesystem metadata version; changed attachments refuse."
|
|
380
|
+
},
|
|
365
381
|
"board": {
|
|
366
382
|
"type": "string",
|
|
367
383
|
"description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
|
|
@@ -561,14 +577,6 @@
|
|
|
561
577
|
"type": "string",
|
|
562
578
|
"description": "Pushed branch head for a source verdict or subtask, or merged commit for final arc completion."
|
|
563
579
|
},
|
|
564
|
-
"ci_run": {
|
|
565
|
-
"type": "integer",
|
|
566
|
-
"description": "Legacy receipt field; deployment is not a board completion requirement."
|
|
567
|
-
},
|
|
568
|
-
"deployed_sha": {
|
|
569
|
-
"type": "string",
|
|
570
|
-
"description": "Legacy receipt field; deployment is not a board completion requirement."
|
|
571
|
-
},
|
|
572
580
|
"branch": {
|
|
573
581
|
"type": "string",
|
|
574
582
|
"description": "The pushed worktree branch the reviewer inspected."
|